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    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/// Resolved paths under the oxi home directory.
38#[derive(Debug, Clone)]
39pub struct OxiPaths {
40    /// Root directory (`$OXI_HOME` or `$HOME/.oxi`).
41    pub home: PathBuf,
42    /// `auth.json` location.
43    pub auth: PathBuf,
44    /// `settings.toml` location.
45    pub config: PathBuf,
46    /// Sessions directory.
47    pub sessions: PathBuf,
48    /// Skills root.
49    pub skills: PathBuf,
50}
51
52impl OxiPaths {
53    /// Resolve from the conventional home directory.
54    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    /// Default — uses `$OXI_HOME` or `$HOME/.oxi`.
66    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
73/// Build an `Oxi` engine wired with file-based port implementations.
74///
75/// This is the **composition root** for oxi-cli. The catalog port
76/// performs network I/O during `init()`. Errors there fall back to
77/// a noop catalog so the user can re-run `oxi refresh` later.
78pub async fn build_oxi(paths: &OxiPaths) -> Result<Oxi> {
79    build_oxi_with_catalog(paths, build_catalog_config(paths)).await
80}
81
82/// Build an `Oxi` engine with a custom catalog config. Useful for
83/// tests (e.g. pointing the catalog at a tempdir).
84pub 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
165/// Build a `CatalogConfig` rooted at `paths.home`.
166fn 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
184/// Resolve local-discovery URLs from environment.
185///
186/// `OXI_LOCAL_DISCOVERY` is a comma-separated list of base URLs.
187fn 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
199/// Spawn a background task that drains the catalog event channel and
200/// logs at info level.
201pub 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
248// ── Memory embedding provider (Hindsight ④ + Gap-1 wiring) ────
249
250/// Build the embedding provider configured by the user.
251///
252/// Returns `None` when `settings.embedding_provider == "none"`,
253/// when base URL / API key are missing, or when the remote provider
254/// fails to construct. Failures are non-fatal.
255pub 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
264/// Construct a `RemoteEmbeddingProvider` from settings.
265fn 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
291// ── Memory backend helpers (Hindsight ④) ──────────────────────────────
292
293/// Create a memory backend if memory is enabled in settings.
294///
295/// Returns `None` when `memory_enabled` is false or the database
296/// cannot be opened.
297pub 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    // Ensure the parent directory exists.
311    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
346/// Wrap a memory backend with the LLM/heuristic fact extractor.
347pub 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
355/// Build a project-memory recall block for injection into the system
356/// prompt. Returns an empty string when no memories exist.
357pub 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
375/// Build the autonomous-memory read-path block (omp `read-path.md`)
376/// by reading `<memory-root>/memory_summary.md` if it exists.
377pub 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
386/// Store a session summary into the memory backend.
387pub 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
397/// Open (or create) the autonomous-memory pipeline DB and spawn the
398/// background Phase-1 / Phase-2 workers. Returns `None` when the
399/// pipeline is disabled (default).
400///
401/// When `oxi` is `Some`, the pipeline resolves a memory extraction
402/// model from settings and creates a provider for actual LLM calls.
403/// Without it, workers run but skip LLM-dependent work.
404pub 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    // Resolve memory extraction model + provider from the Oxi engine.
423    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}