1use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use anyhow::{Context, Result};
22
23use oxi_sdk::Oxi;
24use oxi_sdk::fs::{
25 FileAuthProvider, FileConfigStore, FileModelCatalog, FilePersonaProvider, FileSkillLoader,
26 FileStateStore, SimpleAccessGate, TomlCapabilityResolver,
27};
28use oxi_sdk::inmem::{
29 CountingResourceMonitor, InMemoryCronScheduler, InMemoryMemoryStore, InProcessEventBus,
30};
31use oxi_sdk::ports::catalog::CatalogEvent;
32use oxi_sdk::ports::fs::CatalogConfig;
33
34#[derive(Debug, Clone)]
36pub struct OxiPaths {
37 pub home: PathBuf,
39 pub auth: PathBuf,
41 pub config: PathBuf,
43 pub sessions: PathBuf,
45 pub skills: PathBuf,
47}
48
49impl OxiPaths {
50 pub fn from_home(home: impl Into<PathBuf>) -> Self {
52 let home = home.into();
53 Self {
54 auth: home.join("auth.json"),
55 config: home.join("settings.toml"),
56 sessions: home.join("sessions"),
57 skills: home.join("skills"),
58 home,
59 }
60 }
61
62 pub fn default_paths() -> Result<Self> {
64 oxi_sdk::fs::home_dir()
65 .map(Self::from_home)
66 .context("could not resolve oxi home directory")
67 }
68}
69
70pub async fn build_oxi(paths: &OxiPaths) -> Result<Oxi> {
80 build_oxi_with_catalog(paths, build_catalog_config(paths)).await
81}
82
83pub async fn build_oxi_with_catalog(
86 paths: &OxiPaths,
87 catalog_config: CatalogConfig,
88) -> Result<Oxi> {
89 ensure_parent(&paths.auth)?;
90 ensure_parent(&paths.config)?;
91 ensure_parent(&paths.sessions)?;
92
93 let catalog: Arc<dyn oxi_sdk::ports::catalog::ModelCatalog> =
97 match FileModelCatalog::init(catalog_config).await {
98 Ok(c) => c,
99 Err(e) => {
100 tracing::warn!(error = %e, "catalog init failed; continuing with noop");
101 oxi_sdk::NoopModelCatalog::new()
102 }
103 };
104
105 let oxi = oxi_sdk::OxiBuilder::new()
106 .with_builtins()
107 .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
108 .with_auth(Arc::new(FileAuthProvider::new(&paths.auth)))
109 .with_config(Arc::new(FileConfigStore::new(&paths.config)))
110 .with_skills(Arc::new(FileSkillLoader::single(&paths.skills)))
111 .with_personas(Arc::new(FilePersonaProvider::new(
112 paths.home.join("personas"),
113 )))
114 .with_access(Arc::new(SimpleAccessGate::from_file(
115 paths.home.join("access.toml"),
116 )))
117 .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
118 paths.home.join("capabilities.toml"),
119 )))
120 .with_event_bus(InProcessEventBus::new(64))
121 .with_memory(Arc::new(InMemoryMemoryStore::new()))
122 .with_cron(Arc::new(InMemoryCronScheduler::new()))
123 .with_resources(Arc::new(CountingResourceMonitor::new()))
124 .with_catalog(catalog)
125 .build();
126
127 Ok(oxi)
128}
129
130fn build_catalog_config(paths: &OxiPaths) -> CatalogConfig {
133 CatalogConfig {
134 cache_path: paths.home.join("cache").join("models-dev.json"),
135 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
136 override_path: paths.home.join("catalog").join("overrides.toml"),
137 mtime_window: std::time::Duration::from_secs(60 * 60),
138 fetch_enabled: std::env::var("OXI_MODELS_DEV_DISABLE_FETCH")
139 .ok()
140 .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
141 .unwrap_or(true),
142 models_dev_url: std::env::var("OXI_MODELS_DEV_URL")
143 .unwrap_or_else(|_| "https://models.dev".to_string()),
144 user_agent: format!("oxi-cli/{}", env!("CARGO_PKG_VERSION")),
145 local_discovery_urls: local_discovery_from_env(),
146 snapshot_path: paths.home.join("cache").join("models-dev.json"),
147 }
148}
149
150fn local_discovery_from_env() -> Vec<String> {
155 std::env::var("OXI_LOCAL_DISCOVERY")
156 .ok()
157 .map(|s| {
158 s.split(',')
159 .map(|u| u.trim().to_string())
160 .filter(|u| !u.is_empty())
161 .collect()
162 })
163 .unwrap_or_default()
164}
165
166pub fn spawn_catalog_event_logger(
174 catalog: Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>,
175) -> tokio::task::JoinHandle<()> {
176 let mut rx = catalog.subscribe();
177 tokio::spawn(async move {
178 while let Ok(event) = rx.recv().await {
179 match event {
180 CatalogEvent::Updated {
181 provider_count,
182 model_count,
183 } => {
184 tracing::info!(provider_count, model_count, "catalog refreshed");
185 }
186 CatalogEvent::RefreshFailed { reason, .. } => {
187 tracing::warn!(reason, "catalog refresh failed");
188 }
189 CatalogEvent::OverrideApplied {
190 path,
191 provider_overrides,
192 model_overrides,
193 } => {
194 tracing::info!(
195 path = %path.display(),
196 provider_overrides,
197 model_overrides,
198 "catalog overrides applied"
199 );
200 }
201 CatalogEvent::LocalDiscovered {
202 base_url,
203 model_count,
204 } => {
205 tracing::info!(base_url, model_count, "local models discovered");
206 }
207 }
208 }
209 })
210}
211
212fn ensure_parent(path: &Path) -> Result<()> {
213 if let Some(parent) = path.parent() {
214 std::fs::create_dir_all(parent)
215 .with_context(|| format!("create_dir_all {}", parent.display()))?;
216 }
217 Ok(())
218}
219
220pub fn create_memory_backend(
226 settings: &crate::store::settings::Settings,
227) -> Option<Arc<dyn oxi_agent::tools::MemoryBackend>> {
228 if !settings.memory_enabled {
229 return None;
230 }
231 let db_path = settings.memory_db_path.clone().unwrap_or_else(|| {
232 dirs::home_dir()
233 .unwrap_or_default()
234 .join(".oxi")
235 .join("memory")
236 .join("project.db")
237 });
238 if let Some(parent) = db_path.parent() {
240 let _ = std::fs::create_dir_all(parent);
241 }
242 match crate::store::memory_sqlite::SqliteMemoryStore::open(&db_path) {
243 Ok(store) => Some(Arc::new(store)),
244 Err(e) => {
245 tracing::warn!(
246 "Failed to open memory database at {}: {e}",
247 db_path.display()
248 );
249 None
250 }
251 }
252}
253
254pub async fn build_memory_recall(
257 backend: &dyn oxi_agent::tools::MemoryBackend,
258 subject: &str,
259) -> String {
260 match backend.list(subject).await {
261 Ok(items) if !items.is_empty() => {
262 let mut block = String::from(
263 "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
264 );
265 for item in &items {
266 block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
267 }
268 block
269 }
270 _ => String::new(),
271 }
272}
273
274pub async fn session_reflect(
276 backend: &dyn oxi_agent::tools::MemoryBackend,
277 subject: &str,
278 summary: &str,
279) {
280 if let Err(e) = backend.put(summary, "summary", subject).await {
281 tracing::warn!("Failed to store session memory: {e}");
282 }
283}
284#[cfg(test)]
285mod tests {
286
287 use super::*;
288
289 #[test]
290 fn paths_are_consistent() {
291 let p = OxiPaths::from_home("/tmp/oxi-test");
292 assert!(p.auth.starts_with("/tmp/oxi-test"));
293 assert!(p.config.starts_with("/tmp/oxi-test"));
294 assert!(p.sessions.starts_with("/tmp/oxi-test"));
295 assert!(p.skills.starts_with("/tmp/oxi-test"));
296 }
297
298 #[tokio::test]
299 async fn build_oxi_succeeds() {
300 let tmp = tempfile::TempDir::new().unwrap();
301 let paths = OxiPaths::from_home(tmp.path());
302 let oxi = build_oxi(&paths).await.unwrap();
303 let _ = oxi.ports().state;
305 }
306}