1use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use anyhow::{Context, Result};
16
17use oxi_sdk::Oxi;
18use oxi_sdk::fs::{
19 FileConfigStore, FileModelCatalog, FilePersonaProvider, FileSkillLoader, FileStateStore,
20 SimpleAccessGate, TomlCapabilityResolver,
21};
22use oxi_sdk::inmem::{
23 CountingResourceMonitor, InMemoryCronScheduler, InMemoryMemoryStore, InProcessEventBus,
24};
25use oxi_sdk::ports::InternalUrlRouter;
26use oxi_sdk::ports::catalog::CatalogEvent;
27use oxi_sdk::ports::fs::CatalogConfig;
28use oxi_sdk::ports::inmem::url_router::CompositeUrlRouter;
29
30use crate::internal_urls::issue_handler::IssueProtocolHandler;
31use crate::internal_urls::memory_handler::MemoryProtocolHandler;
32use crate::internal_urls::pr_handler::PrProtocolHandler;
33use crate::store::extracting_backend;
34use crate::store::memory_summary;
35use crate::store::memory_workers;
36
37#[derive(Debug, Clone)]
39pub struct OxiPaths {
40 pub home: PathBuf,
42 pub auth: PathBuf,
44 pub config: PathBuf,
46 pub sessions: PathBuf,
48 pub skills: PathBuf,
50}
51
52impl OxiPaths {
53 pub fn from_home(home: impl Into<PathBuf>) -> Self {
55 let home = home.into();
56 Self {
57 auth: home.join("auth.json"),
58 config: home.join("settings.toml"),
59 sessions: home.join("sessions"),
60 skills: home.join("skills"),
61 home,
62 }
63 }
64
65 pub fn default_paths() -> Result<Self> {
67 oxi_sdk::fs::home_dir()
68 .map(Self::from_home)
69 .context("could not resolve oxi home directory")
70 }
71}
72
73pub async fn build_oxi(paths: &OxiPaths) -> Result<Oxi> {
79 build_oxi_with_catalog(paths, build_catalog_config(paths)).await
80}
81
82pub async fn build_oxi_with_catalog(
85 paths: &OxiPaths,
86 catalog_config: CatalogConfig,
87) -> Result<Oxi> {
88 ensure_parent(&paths.auth)?;
89 ensure_parent(&paths.config)?;
90 ensure_parent(&paths.sessions)?;
91
92 let catalog: Arc<dyn oxi_sdk::ports::catalog::ModelCatalog> =
93 match FileModelCatalog::init(catalog_config).await {
94 Ok(c) => c,
95 Err(e) => {
96 tracing::warn!(error = %e, "catalog init failed; continuing with noop");
97 oxi_sdk::NoopModelCatalog::new()
98 }
99 };
100
101 let skill_loader = Arc::new(FileSkillLoader::single(&paths.skills));
102 let rule_registry: Arc<dyn oxi_sdk::ports::RuleRegistry> =
103 Arc::new(oxi_sdk::ports::NoopRuleRegistry);
104 let agent_artifact_store = crate::internal_urls::agent_handler::AgentArtifactStore::new();
105 let local_root = paths.home.join("local-artifacts");
106
107 let oxi = oxi_sdk::OxiBuilder::new()
108 .with_builtins()
109 .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
110 .with_auth(crate::store::auth_storage::shared_auth_storage())
111 .with_config(Arc::new(FileConfigStore::new(&paths.config)))
112 .with_skills(skill_loader.clone())
113 .with_personas(Arc::new(FilePersonaProvider::new(
114 paths.home.join("personas"),
115 )))
116 .with_access(Arc::new(SimpleAccessGate::from_file(
117 paths.home.join("access.toml"),
118 )))
119 .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
120 paths.home.join("capabilities.toml"),
121 )))
122 .with_event_bus(InProcessEventBus::new(64))
123 .with_memory(Arc::new(InMemoryMemoryStore::new()))
124 .with_cron(Arc::new(InMemoryCronScheduler::new()))
125 .with_resources(Arc::new(CountingResourceMonitor::new()))
126 .with_catalog(catalog)
127 .with_url_router(build_url_router(
128 paths,
129 skill_loader,
130 rule_registry,
131 agent_artifact_store,
132 local_root,
133 ))
134 .build();
135
136 Ok(oxi)
137}
138fn build_url_router(
139 paths: &OxiPaths,
140 skill_loader: Arc<dyn oxi_sdk::ports::SkillLoader>,
141 rule_registry: Arc<dyn oxi_sdk::ports::RuleRegistry>,
142 agent_store: crate::internal_urls::agent_handler::AgentArtifactStore,
143 local_root: PathBuf,
144) -> Arc<dyn InternalUrlRouter> {
145 let memory_root = paths.home.join("memory");
146 let router = CompositeUrlRouter::new();
147 router.register(Arc::new(MemoryProtocolHandler::new(memory_root)));
148 router.register(Arc::new(IssueProtocolHandler));
149 router.register(Arc::new(PrProtocolHandler));
150 router.register(Arc::new(
151 crate::internal_urls::skill_handler::SkillProtocolHandler::new(skill_loader),
152 ));
153 router.register(Arc::new(
154 crate::internal_urls::rule_handler::RuleProtocolHandler::new(rule_registry),
155 ));
156 router.register(Arc::new(
157 crate::internal_urls::agent_handler::AgentProtocolHandler::new(agent_store),
158 ));
159 router.register(Arc::new(
160 crate::internal_urls::local_handler::LocalProtocolHandler::new(local_root),
161 ));
162 Arc::new(router)
163}
164
165fn build_catalog_config(paths: &OxiPaths) -> CatalogConfig {
167 CatalogConfig {
168 cache_path: paths.home.join("cache").join("models-dev.json"),
169 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
170 override_path: paths.home.join("catalog").join("overrides.toml"),
171 mtime_window: std::time::Duration::from_secs(60 * 60),
172 fetch_enabled: std::env::var("OXI_MODELS_DEV_DISABLE_FETCH")
173 .ok()
174 .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
175 .unwrap_or(true),
176 models_dev_url: std::env::var("OXI_MODELS_DEV_URL")
177 .unwrap_or_else(|_| "https://models.dev".to_string()),
178 user_agent: format!("oxi-cli/{}", env!("CARGO_PKG_VERSION")),
179 local_discovery_urls: local_discovery_from_env(),
180 snapshot_path: paths.home.join("cache").join("models-dev.json"),
181 }
182}
183
184fn local_discovery_from_env() -> Vec<String> {
188 std::env::var("OXI_LOCAL_DISCOVERY")
189 .ok()
190 .map(|s| {
191 s.split(',')
192 .map(|u| u.trim().to_string())
193 .filter(|u| !u.is_empty())
194 .collect()
195 })
196 .unwrap_or_default()
197}
198
199pub fn spawn_catalog_event_logger(
202 catalog: Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>,
203) -> tokio::task::JoinHandle<()> {
204 let mut rx = catalog.subscribe();
205 tokio::spawn(async move {
206 while let Ok(event) = rx.recv().await {
207 match event {
208 CatalogEvent::Updated {
209 provider_count,
210 model_count,
211 } => {
212 tracing::info!(provider_count, model_count, "catalog refreshed");
213 }
214 CatalogEvent::RefreshFailed { reason, .. } => {
215 tracing::warn!(reason, "catalog refresh failed");
216 }
217 CatalogEvent::OverrideApplied {
218 path,
219 provider_overrides,
220 model_overrides,
221 } => {
222 tracing::info!(
223 path = %path.display(),
224 provider_overrides,
225 model_overrides,
226 "catalog overrides applied"
227 );
228 }
229 CatalogEvent::LocalDiscovered {
230 base_url,
231 model_count,
232 } => {
233 tracing::info!(base_url, model_count, "local models discovered");
234 }
235 }
236 }
237 })
238}
239
240fn ensure_parent(path: &Path) -> Result<()> {
241 if let Some(parent) = path.parent() {
242 std::fs::create_dir_all(parent)
243 .with_context(|| format!("create_dir_all {}", parent.display()))?;
244 }
245 Ok(())
246}
247
248pub fn build_embedding_provider(
256 settings: &crate::store::settings::Settings,
257) -> Option<Arc<dyn oxi_mnemopi::EmbeddingProvider>> {
258 match settings.embedding_provider.as_str() {
259 "remote" => build_remote_embedding_provider(settings),
260 _ => None,
261 }
262}
263
264fn build_remote_embedding_provider(
266 settings: &crate::store::settings::Settings,
267) -> Option<Arc<dyn oxi_mnemopi::EmbeddingProvider>> {
268 let base_url = settings.embedding_base_url.as_deref()?.trim();
269 if base_url.is_empty() {
270 tracing::warn!("memory: embedding_provider='remote' but embedding_base_url is empty");
271 return None;
272 }
273 let api_key = std::env::var(&settings.embedding_api_key_env).ok()?;
274 if api_key.is_empty() {
275 tracing::warn!(
276 "memory: embedding_provider='remote' but env var {} is unset",
277 settings.embedding_api_key_env
278 );
279 return None;
280 }
281 let model = if settings.embedding_model.is_empty() {
282 "text-embedding-3-small".to_string()
283 } else {
284 settings.embedding_model.clone()
285 };
286 Some(Arc::new(oxi_mnemopi::RemoteEmbeddingProvider::new(
287 base_url, &api_key, &model,
288 )))
289}
290
291pub fn create_memory_backend(
298 settings: &crate::store::settings::Settings,
299) -> Option<Arc<dyn oxi_agent::tools::MemoryBackend>> {
300 if !settings.memory_enabled {
301 return None;
302 }
303 let db_path = settings.memory_db_path.clone().unwrap_or_else(|| {
304 dirs::home_dir()
305 .unwrap_or_default()
306 .join(".oxi")
307 .join("memory")
308 .join("project.db")
309 });
310 if let Some(parent) = db_path.parent() {
312 let _ = std::fs::create_dir_all(parent);
313 }
314 if settings.mnemopi_engine {
315 let embedding_provider = build_embedding_provider(settings);
316 let embedding_model = settings.embedding_model.clone();
317 match crate::store::memory_mnemopi::MnemopiMemoryBackend::open(
318 &db_path,
319 "default",
320 embedding_provider,
321 &embedding_model,
322 ) {
323 Ok(store) => Some(Arc::new(store)),
324 Err(e) => {
325 tracing::warn!(
326 "Failed to open Mnemopi engine at {}: {e}",
327 db_path.display()
328 );
329 None
330 }
331 }
332 } else {
333 match crate::store::memory_sqlite::SqliteMemoryStore::open(&db_path) {
334 Ok(store) => Some(Arc::new(store)),
335 Err(e) => {
336 tracing::warn!(
337 "Failed to open memory database at {}: {e}",
338 db_path.display()
339 );
340 None
341 }
342 }
343 }
344}
345
346pub fn wrap_extracting(
348 backend: Arc<dyn oxi_agent::tools::MemoryBackend>,
349 settings: &crate::store::settings::Settings,
350 oxi: Option<&oxi_sdk::Oxi>,
351) -> Arc<dyn oxi_agent::tools::MemoryBackend> {
352 extracting_backend::wrap_with_extractor(backend, settings, oxi)
353}
354
355pub async fn build_memory_recall(
358 backend: &dyn oxi_agent::tools::MemoryBackend,
359 subject: &str,
360) -> String {
361 match backend.list(subject).await {
362 Ok(items) if !items.is_empty() => {
363 let mut block = String::from(
364 "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
365 );
366 for item in &items {
367 block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
368 }
369 block
370 }
371 _ => String::new(),
372 }
373}
374
375pub fn read_path_block(home: &Path, cwd: &Path) -> Option<String> {
378 let cwd_str = cwd.to_string_lossy().to_string();
379 let memory_root = memory_summary::memory_root(home, &cwd_str);
380 let (_, memory_summary_text) =
381 memory_summary::load_consolidated_artifacts(&memory_root).ok()?;
382 let summary = memory_summary_text?;
383 Some(memory_summary::render_read_path(Some(&summary), None))
384}
385
386pub async fn session_reflect(
388 backend: &dyn oxi_agent::tools::MemoryBackend,
389 subject: &str,
390 summary: &str,
391) {
392 if let Err(e) = backend.put(summary, "summary", subject).await {
393 tracing::warn!("Failed to store session memory: {e}");
394 }
395}
396
397pub fn start_memory_pipeline(
405 settings: &crate::store::settings::Settings,
406 cwd: &Path,
407 oxi: Option<&oxi_sdk::Oxi>,
408) -> Option<tokio::task::JoinHandle<()>> {
409 let backend = settings.memory_backend.as_deref().unwrap_or("off");
410 if backend != "local" {
411 tracing::debug!("autonomous memory pipeline: backend='{backend}' — disabled");
412 return None;
413 }
414
415 let home = crate::store::settings::Settings::settings_dir().ok()?;
416 let db_path = memory_workers::pipeline_db_path(&home);
417 let sessions_dir = home.join("sessions");
418
419 let cwd_str = cwd.to_string_lossy().to_string();
420 let memory_root = memory_summary::memory_root(&home, &cwd_str);
421
422 let (provider, model) = if let Some(oxi) = oxi {
424 let model_id = if settings.memory_llm_extract_model.is_empty() {
425 settings.effective_model(None).unwrap_or_default()
426 } else {
427 settings.memory_llm_extract_model.clone()
428 };
429 if model_id.is_empty() {
430 tracing::warn!("memory pipeline: no model configured for extraction");
431 (None, None)
432 } else {
433 match oxi.resolve_model(&model_id) {
434 Ok(model) => match oxi.create_provider(&model.provider) {
435 Ok(provider) => (Some(provider), Some(model)),
436 Err(e) => {
437 tracing::warn!("memory pipeline: provider creation failed: {e}");
438 (None, None)
439 }
440 },
441 Err(e) => {
442 tracing::warn!("memory pipeline: model resolution failed: {e}");
443 (None, None)
444 }
445 }
446 }
447 } else {
448 tracing::warn!("memory pipeline: no Oxi engine, LLM calls will be skipped");
449 (None, None)
450 };
451
452 let poll_interval = std::time::Duration::from_secs(60);
453
454 let handle = tokio::task::spawn_blocking(move || {
455 let rt = tokio::runtime::Builder::new_current_thread()
456 .enable_all()
457 .build()
458 .expect("memory pipeline runtime");
459 rt.block_on(async move {
460 let conn = match memory_workers::open_db(&db_path) {
461 Ok(c) => c,
462 Err(e) => {
463 tracing::warn!(
464 "autonomous memory pipeline: open_db({}) failed: {e}",
465 db_path.display()
466 );
467 return;
468 }
469 };
470
471 tracing::info!(
472 "autonomous memory pipeline: workers started (provider={})",
473 if provider.is_some() { "wired" } else { "none" }
474 );
475
476 loop {
477 let now = chrono::Utc::now().timestamp();
478
479 match memory_workers::run_stage1_iteration(
480 &conn,
481 &sessions_dir,
482 &cwd_str,
483 now,
484 provider.as_ref(),
485 model.as_ref(),
486 )
487 .await
488 {
489 Ok(true) => tracing::debug!("memory pipeline: stage 1 processed a job"),
490 Ok(false) => tracing::trace!("memory pipeline: stage 1 idle"),
491 Err(e) => tracing::warn!("memory pipeline: stage 1 error: {e}"),
492 }
493
494 match memory_workers::run_stage2_iteration(
495 &conn,
496 &memory_root,
497 &cwd_str,
498 now,
499 provider.as_ref(),
500 model.as_ref(),
501 )
502 .await
503 {
504 Ok(true) => tracing::info!("memory pipeline: stage 2 consolidated"),
505 Ok(false) => tracing::trace!("memory pipeline: stage 2 idle"),
506 Err(e) => tracing::warn!("memory pipeline: stage 2 error: {e}"),
507 }
508
509 tokio::time::sleep(poll_interval).await;
510 }
511 });
512 });
513 Some(handle)
514}
515
516#[cfg(test)]
517mod tests {
518
519 use super::*;
520
521 #[test]
522 fn paths_are_consistent() {
523 let p = OxiPaths::from_home("/tmp/oxi-test");
524 assert!(p.auth.starts_with("/tmp/oxi-test"));
525 assert!(p.config.starts_with("/tmp/oxi-test"));
526 assert!(p.sessions.starts_with("/tmp/oxi-test"));
527 assert!(p.skills.starts_with("/tmp/oxi-test"));
528 }
529
530 #[tokio::test]
531 async fn build_oxi_succeeds() {
532 let tmp = tempfile::TempDir::new().unwrap();
533 let paths = OxiPaths::from_home(tmp.path());
534 let oxi = build_oxi(&paths).await.unwrap();
535 let _ = oxi.ports().state;
536 }
537}