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
4//! the `Oxi` engine. Future run modes (TUI / print / RPC) build on
5//! top of the `Oxi` produced here.
6//!
7//! Migration note:
8//! - Legacy `App` in `lib.rs` is the single-user interactive
9//!   composition. This module is the port-based composition.
10//! - Both paths coexist; new run modes consume `build_oxi(...)` here.
11
12use 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/// Resolved paths under the oxi home directory.
36#[derive(Debug, Clone)]
37pub struct OxiPaths {
38    /// Root directory (`$OXI_HOME` or `$HOME/.oxi`).
39    pub home: PathBuf,
40    /// `auth.json` location.
41    pub auth: PathBuf,
42    /// `settings.toml` location.
43    pub config: PathBuf,
44    /// Sessions directory.
45    pub sessions: PathBuf,
46    /// Skills root.
47    pub skills: PathBuf,
48}
49
50impl OxiPaths {
51    /// Resolve from the conventional home directory.
52    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    /// Default — uses `$OXI_HOME` or `$HOME/.oxi`.
64    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
71/// Build an `Oxi` engine wired with file-based port implementations.
72///
73/// This is the **composition root** for oxi-cli. The catalog port
74/// performs network I/O during `init()`. Errors there fall back to
75/// a noop catalog so the user can re-run `oxi refresh` later.
76pub async fn build_oxi(paths: &OxiPaths) -> Result<Oxi> {
77    build_oxi_with_catalog(paths, build_catalog_config(paths)).await
78}
79
80/// Build an `Oxi` engine with a custom catalog config. Useful for
81/// tests (e.g. pointing the catalog at a tempdir).
82pub 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
125/// Build the `InternalUrlRouter` wired into [`OxiBuilder::with_url_router`].
126///
127/// Always registers `MemoryProtocolHandler` so `memory://…` URLs are
128/// resolvable regardless of whether the agent is running.
129fn 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
136/// Build a `CatalogConfig` rooted at `paths.home`.
137fn 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
155/// Resolve local-discovery URLs from environment.
156///
157/// `OXI_LOCAL_DISCOVERY` is a comma-separated list of base URLs.
158fn 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
170/// Spawn a background task that drains the catalog event channel and
171/// logs at info level.
172pub 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
219// ── Memory embedding provider (Hindsight ④ + Gap-1 wiring) ────
220
221/// Build the embedding provider configured by the user.
222///
223/// Returns `None` when `settings.embedding_provider == "none"`,
224/// when base URL / API key are missing, or when the remote provider
225/// fails to construct. Failures are non-fatal.
226pub 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
235/// Construct a `RemoteEmbeddingProvider` from settings.
236fn 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
262// ── Memory backend helpers (Hindsight ④) ──────────────────────────────
263
264/// Create a memory backend if memory is enabled in settings.
265///
266/// Returns `None` when `memory_enabled` is false or the database
267/// cannot be opened.
268pub 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    // Ensure the parent directory exists.
282    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
317/// Wrap a memory backend with the LLM/heuristic fact extractor.
318pub 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
326/// Build a project-memory recall block for injection into the system
327/// prompt. Returns an empty string when no memories exist.
328pub 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
346/// Build the autonomous-memory read-path block (omp `read-path.md`)
347/// by reading `<memory-root>/memory_summary.md` if it exists.
348pub 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
357/// Store a session summary into the memory backend.
358pub 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
368/// Open (or create) the autonomous-memory pipeline DB and spawn the
369/// background Phase-1 / Phase-2 workers. Returns `None` when the
370/// pipeline is disabled (default).
371pub 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    // Best-effort spawn. The actual LLM call integration is the
390    // follow-up PR's responsibility.
391    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}