Skip to main content

oxi/
services.rs

1//! Composition root for oxi-cli.
2//!
3//! Wires concrete file-based port implementations (from `oxi-fs`) to the
4//! `Oxi` engine. Future run modes (TUI / print / RPC) build on top of
5//! the `Oxi` produced here.
6//!
7//! # Migration note
8//!
9//! The legacy `App` struct in `lib.rs` is the **single-user interactive**
10//! composition (TUI-driven, in-process). This module is the
11//! **port-based** composition: a `Oxi` engine with persistence, auth,
12//! config, and skills wired via `oxi_sdk::OxiBuilder::with_port_*`.
13//!
14//! Both paths are expected to coexist during the migration. New run modes
15//! should consume `build_oxi(...)` from this module; the legacy `App`
16//! path remains for the interactive TUI until it is fully migrated.
17
18use 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/// Resolved paths under the oxi home directory.
35#[derive(Debug, Clone)]
36pub struct OxiPaths {
37    /// Root directory (`$OXI_HOME` or `$HOME/.oxi`).
38    pub home: PathBuf,
39    /// `auth.json` location.
40    pub auth: PathBuf,
41    /// `settings.toml` location.
42    pub config: PathBuf,
43    /// Sessions directory.
44    pub sessions: PathBuf,
45    /// Skills root.
46    pub skills: PathBuf,
47}
48
49impl OxiPaths {
50    /// Resolve from the conventional home directory.
51    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    /// Default — uses `$OXI_HOME` or `$HOME/.oxi`.
63    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
70/// Build an `Oxi` engine wired with file-based port implementations.
71///
72/// This is the **composition root** for oxi-cli. The catalog port
73/// (`FileModelCatalog`) performs network I/O during `init()` (cache check
74/// + optional one refresh attempt).
75///
76/// Callers that want a fully sync composition should use the catalog's
77/// noop default or pre-construct a `CatalogConfig` with
78/// `fetch_enabled: false`.
79pub async fn build_oxi(paths: &OxiPaths) -> Result<Oxi> {
80    build_oxi_with_catalog(paths, build_catalog_config(paths)).await
81}
82
83/// Build an `Oxi` engine with a custom catalog config. Useful for tests
84/// (e.g. pointing the catalog at a tempdir).
85pub 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    // Initialize the catalog (loads embedded SNAP + cache + overrides).
94    // Errors here are non-fatal — we fall back to noop and let the user
95    // re-run `oxi refresh` to recover.
96    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
130/// Build a `CatalogConfig` rooted at `paths.home` (default cache, ETag,
131/// and override file locations).
132fn 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
150/// Resolve local-discovery URLs from environment.
151///
152/// `OXI_LOCAL_DISCOVERY` is a comma-separated list of base URLs (e.g.
153/// `http://localhost:11434/v1,http://localhost:1234/v1`). Empty default.
154fn 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
166/// Spawn a background task that drains the catalog event channel and logs
167/// at info level. The returned `JoinHandle` can be dropped (the task runs
168/// for the lifetime of the program — it owns its Receiver clone).
169///
170/// This is a thin convenience — production consumers should subscribe to
171/// `oxi.catalog().subscribe()` directly and react to events (UI refresh,
172/// cache invalidation, etc.).
173pub 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
220// ── Memory backend helpers (Hindsight ④) ──────────────────────────────
221
222/// Create a memory backend if memory is enabled in settings.
223/// Returns `None` when `memory_enabled` is false or the database cannot
224/// be opened.
225pub 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    // Ensure the parent directory exists.
239    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
254/// Build a project-memory recall block for injection into the system prompt.
255/// Returns an empty string when no memories exist.
256pub 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
274/// Store a session summary into the memory backend.
275pub 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        // State is wired (even if we don't call it).
304        let _ = oxi.ports().state;
305    }
306}