1use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use anyhow::{Context, Result};
16
17use oxi_sdk::Oxi;
18use oxi_sdk::fs::{
19 FileAuthProvider, FileConfigStore, FileModelCatalog, FilePersonaProvider, FileSkillLoader,
20 FileStateStore, 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::memory_handler::MemoryProtocolHandler;
31use crate::store::extracting_backend;
32use crate::store::memory_summary;
33use crate::store::memory_workers;
34
35#[derive(Debug, Clone)]
37pub struct OxiPaths {
38 pub home: PathBuf,
40 pub auth: PathBuf,
42 pub config: PathBuf,
44 pub sessions: PathBuf,
46 pub skills: PathBuf,
48}
49
50impl OxiPaths {
51 pub fn from_home(home: impl Into<PathBuf>) -> Self {
53 let home = home.into();
54 Self {
55 auth: home.join("auth.json"),
56 config: home.join("settings.toml"),
57 sessions: home.join("sessions"),
58 skills: home.join("skills"),
59 home,
60 }
61 }
62
63 pub fn default_paths() -> Result<Self> {
65 oxi_sdk::fs::home_dir()
66 .map(Self::from_home)
67 .context("could not resolve oxi home directory")
68 }
69}
70
71pub async fn build_oxi(paths: &OxiPaths) -> Result<Oxi> {
77 build_oxi_with_catalog(paths, build_catalog_config(paths)).await
78}
79
80pub async fn build_oxi_with_catalog(
83 paths: &OxiPaths,
84 catalog_config: CatalogConfig,
85) -> Result<Oxi> {
86 ensure_parent(&paths.auth)?;
87 ensure_parent(&paths.config)?;
88 ensure_parent(&paths.sessions)?;
89
90 let catalog: Arc<dyn oxi_sdk::ports::catalog::ModelCatalog> =
91 match FileModelCatalog::init(catalog_config).await {
92 Ok(c) => c,
93 Err(e) => {
94 tracing::warn!(error = %e, "catalog init failed; continuing with noop");
95 oxi_sdk::NoopModelCatalog::new()
96 }
97 };
98
99 let oxi = oxi_sdk::OxiBuilder::new()
100 .with_builtins()
101 .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
102 .with_auth(Arc::new(FileAuthProvider::new(&paths.auth)))
103 .with_config(Arc::new(FileConfigStore::new(&paths.config)))
104 .with_skills(Arc::new(FileSkillLoader::single(&paths.skills)))
105 .with_personas(Arc::new(FilePersonaProvider::new(
106 paths.home.join("personas"),
107 )))
108 .with_access(Arc::new(SimpleAccessGate::from_file(
109 paths.home.join("access.toml"),
110 )))
111 .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
112 paths.home.join("capabilities.toml"),
113 )))
114 .with_event_bus(InProcessEventBus::new(64))
115 .with_memory(Arc::new(InMemoryMemoryStore::new()))
116 .with_cron(Arc::new(InMemoryCronScheduler::new()))
117 .with_resources(Arc::new(CountingResourceMonitor::new()))
118 .with_catalog(catalog)
119 .with_url_router(build_memory_url_router(paths))
120 .build();
121
122 Ok(oxi)
123}
124
125fn build_memory_url_router(paths: &OxiPaths) -> Arc<dyn InternalUrlRouter> {
130 let memory_root = paths.home.join("memory");
131 let router = CompositeUrlRouter::new();
132 router.register(Arc::new(MemoryProtocolHandler::new(memory_root)));
133 Arc::new(router)
134}
135
136fn build_catalog_config(paths: &OxiPaths) -> CatalogConfig {
138 CatalogConfig {
139 cache_path: paths.home.join("cache").join("models-dev.json"),
140 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
141 override_path: paths.home.join("catalog").join("overrides.toml"),
142 mtime_window: std::time::Duration::from_secs(60 * 60),
143 fetch_enabled: std::env::var("OXI_MODELS_DEV_DISABLE_FETCH")
144 .ok()
145 .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
146 .unwrap_or(true),
147 models_dev_url: std::env::var("OXI_MODELS_DEV_URL")
148 .unwrap_or_else(|_| "https://models.dev".to_string()),
149 user_agent: format!("oxi-cli/{}", env!("CARGO_PKG_VERSION")),
150 local_discovery_urls: local_discovery_from_env(),
151 snapshot_path: paths.home.join("cache").join("models-dev.json"),
152 }
153}
154
155fn local_discovery_from_env() -> Vec<String> {
159 std::env::var("OXI_LOCAL_DISCOVERY")
160 .ok()
161 .map(|s| {
162 s.split(',')
163 .map(|u| u.trim().to_string())
164 .filter(|u| !u.is_empty())
165 .collect()
166 })
167 .unwrap_or_default()
168}
169
170pub fn spawn_catalog_event_logger(
173 catalog: Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>,
174) -> tokio::task::JoinHandle<()> {
175 let mut rx = catalog.subscribe();
176 tokio::spawn(async move {
177 while let Ok(event) = rx.recv().await {
178 match event {
179 CatalogEvent::Updated {
180 provider_count,
181 model_count,
182 } => {
183 tracing::info!(provider_count, model_count, "catalog refreshed");
184 }
185 CatalogEvent::RefreshFailed { reason, .. } => {
186 tracing::warn!(reason, "catalog refresh failed");
187 }
188 CatalogEvent::OverrideApplied {
189 path,
190 provider_overrides,
191 model_overrides,
192 } => {
193 tracing::info!(
194 path = %path.display(),
195 provider_overrides,
196 model_overrides,
197 "catalog overrides applied"
198 );
199 }
200 CatalogEvent::LocalDiscovered {
201 base_url,
202 model_count,
203 } => {
204 tracing::info!(base_url, model_count, "local models discovered");
205 }
206 }
207 }
208 })
209}
210
211fn ensure_parent(path: &Path) -> Result<()> {
212 if let Some(parent) = path.parent() {
213 std::fs::create_dir_all(parent)
214 .with_context(|| format!("create_dir_all {}", parent.display()))?;
215 }
216 Ok(())
217}
218
219pub fn build_embedding_provider(
227 settings: &crate::store::settings::Settings,
228) -> Option<Arc<dyn oxi_mnemopi::EmbeddingProvider>> {
229 match settings.embedding_provider.as_str() {
230 "remote" => build_remote_embedding_provider(settings),
231 _ => None,
232 }
233}
234
235fn build_remote_embedding_provider(
237 settings: &crate::store::settings::Settings,
238) -> Option<Arc<dyn oxi_mnemopi::EmbeddingProvider>> {
239 let base_url = settings.embedding_base_url.as_deref()?.trim();
240 if base_url.is_empty() {
241 tracing::warn!("memory: embedding_provider='remote' but embedding_base_url is empty");
242 return None;
243 }
244 let api_key = std::env::var(&settings.embedding_api_key_env).ok()?;
245 if api_key.is_empty() {
246 tracing::warn!(
247 "memory: embedding_provider='remote' but env var {} is unset",
248 settings.embedding_api_key_env
249 );
250 return None;
251 }
252 let model = if settings.embedding_model.is_empty() {
253 "text-embedding-3-small".to_string()
254 } else {
255 settings.embedding_model.clone()
256 };
257 Some(Arc::new(oxi_mnemopi::RemoteEmbeddingProvider::new(
258 base_url, &api_key, &model,
259 )))
260}
261
262pub fn create_memory_backend(
269 settings: &crate::store::settings::Settings,
270) -> Option<Arc<dyn oxi_agent::tools::MemoryBackend>> {
271 if !settings.memory_enabled {
272 return None;
273 }
274 let db_path = settings.memory_db_path.clone().unwrap_or_else(|| {
275 dirs::home_dir()
276 .unwrap_or_default()
277 .join(".oxi")
278 .join("memory")
279 .join("project.db")
280 });
281 if let Some(parent) = db_path.parent() {
283 let _ = std::fs::create_dir_all(parent);
284 }
285 if settings.mnemopi_engine {
286 let embedding_provider = build_embedding_provider(settings);
287 let embedding_model = settings.embedding_model.clone();
288 match crate::store::memory_mnemopi::MnemopiMemoryBackend::open(
289 &db_path,
290 "default",
291 embedding_provider,
292 &embedding_model,
293 ) {
294 Ok(store) => Some(Arc::new(store)),
295 Err(e) => {
296 tracing::warn!(
297 "Failed to open Mnemopi engine at {}: {e}",
298 db_path.display()
299 );
300 None
301 }
302 }
303 } else {
304 match crate::store::memory_sqlite::SqliteMemoryStore::open(&db_path) {
305 Ok(store) => Some(Arc::new(store)),
306 Err(e) => {
307 tracing::warn!(
308 "Failed to open memory database at {}: {e}",
309 db_path.display()
310 );
311 None
312 }
313 }
314 }
315}
316
317pub fn wrap_extracting(
319 backend: Arc<dyn oxi_agent::tools::MemoryBackend>,
320 settings: &crate::store::settings::Settings,
321 oxi: Option<&oxi_sdk::Oxi>,
322) -> Arc<dyn oxi_agent::tools::MemoryBackend> {
323 extracting_backend::wrap_with_extractor(backend, settings, oxi)
324}
325
326pub async fn build_memory_recall(
329 backend: &dyn oxi_agent::tools::MemoryBackend,
330 subject: &str,
331) -> String {
332 match backend.list(subject).await {
333 Ok(items) if !items.is_empty() => {
334 let mut block = String::from(
335 "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
336 );
337 for item in &items {
338 block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
339 }
340 block
341 }
342 _ => String::new(),
343 }
344}
345
346pub fn read_path_block(home: &Path, cwd: &Path) -> Option<String> {
349 let cwd_str = cwd.to_string_lossy().to_string();
350 let memory_root = memory_summary::memory_root(home, &cwd_str);
351 let (_, memory_summary_text) =
352 memory_summary::load_consolidated_artifacts(&memory_root).ok()?;
353 let summary = memory_summary_text?;
354 Some(memory_summary::render_read_path(Some(&summary), None))
355}
356
357pub async fn session_reflect(
359 backend: &dyn oxi_agent::tools::MemoryBackend,
360 subject: &str,
361 summary: &str,
362) {
363 if let Err(e) = backend.put(summary, "summary", subject).await {
364 tracing::warn!("Failed to store session memory: {e}");
365 }
366}
367
368pub fn start_memory_pipeline(
372 settings: &crate::store::settings::Settings,
373 cwd: &Path,
374 _oxi: Option<&oxi_sdk::Oxi>,
375) -> Option<tokio::task::JoinHandle<()>> {
376 let backend = settings.memory_backend.as_deref().unwrap_or("off");
377 if backend != "local" {
378 tracing::debug!("autonomous memory pipeline: backend='{backend}' — disabled");
379 return None;
380 }
381
382 let home = crate::store::settings::Settings::settings_dir().ok()?;
383 let db_path = memory_workers::pipeline_db_path(&home);
384 let _sessions_dir = home.join("sessions");
385
386 let cwd_str = cwd.to_string_lossy().to_string();
387 let _memory_root = memory_summary::memory_root(&home, &cwd_str);
388
389 let handle = tokio::spawn(async move {
392 let conn = match memory_workers::open_db(&db_path) {
393 Ok(c) => c,
394 Err(e) => {
395 tracing::warn!(
396 "autonomous memory pipeline: open_db({}) failed: {e}",
397 db_path.display()
398 );
399 return;
400 }
401 };
402 let now = chrono::Utc::now().timestamp();
403 let _ = conn;
404 let _ = now;
405 tracing::info!("autonomous memory pipeline: workers spawned (no-op until LLM wired)");
406 });
407 Some(handle)
408}
409
410#[cfg(test)]
411mod tests {
412
413 use super::*;
414
415 #[test]
416 fn paths_are_consistent() {
417 let p = OxiPaths::from_home("/tmp/oxi-test");
418 assert!(p.auth.starts_with("/tmp/oxi-test"));
419 assert!(p.config.starts_with("/tmp/oxi-test"));
420 assert!(p.sessions.starts_with("/tmp/oxi-test"));
421 assert!(p.skills.starts_with("/tmp/oxi-test"));
422 }
423
424 #[tokio::test]
425 async fn build_oxi_succeeds() {
426 let tmp = tempfile::TempDir::new().unwrap();
427 let paths = OxiPaths::from_home(tmp.path());
428 let oxi = build_oxi(&paths).await.unwrap();
429 let _ = oxi.ports().state;
430 }
431}