Skip to main content

oxicode/
services.rs

1//! Composition root for oxicode-cli.
2//!
3//! Wires concrete file-based port implementations (from `oxicode-fs`) to
4//! the `Oxicode` engine. Future run modes (TUI / print / RPC) build on
5//! top of the `Oxicode` 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_oxicode(...)` here.
11
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14use std::sync::Arc;
15
16use anyhow::{Context, Result};
17
18use oxicode_sdk::Oxicode;
19use oxicode_sdk::fs::{
20    FileConfigStore, FileModelCatalog, FilePersonaProvider, FileSkillLoader, FileStateStore,
21    SimpleAccessGate, TomlCapabilityResolver,
22};
23use oxicode_sdk::inmem::{
24    CountingResourceMonitor, InMemoryCronScheduler, InMemoryMemoryStore, InProcessEventBus,
25};
26use oxicode_sdk::ports::InternalUrlRouter;
27use oxicode_sdk::ports::catalog::CatalogEvent;
28use oxicode_sdk::ports::fs::CatalogConfig;
29use oxicode_sdk::ports::inmem::url_router::CompositeUrlRouter;
30
31use crate::internal_urls::issue_handler::IssueProtocolHandler;
32use crate::internal_urls::memory_handler::MemoryProtocolHandler;
33use crate::internal_urls::pr_handler::PrProtocolHandler;
34use crate::store::extracting_backend;
35use crate::store::memory_summary;
36use crate::store::memory_workers;
37
38/// Resolved paths under the oxicode home directory.
39#[derive(Debug, Clone)]
40pub struct OxicodePaths {
41    /// Root directory (`$OXICODE_HOME` or `$HOME/.oxicode`).
42    pub home: PathBuf,
43    /// `auth.json` location.
44    pub auth: PathBuf,
45    /// `settings.toml` location.
46    pub config: PathBuf,
47    /// Sessions directory.
48    pub sessions: PathBuf,
49    /// Skills root.
50    pub skills: PathBuf,
51}
52
53impl OxicodePaths {
54    /// Resolve from the conventional home directory.
55    pub fn from_home(home: impl Into<PathBuf>) -> Self {
56        let home = home.into();
57        Self {
58            auth: home.join("auth.json"),
59            config: home.join("settings.toml"),
60            sessions: home.join("sessions"),
61            skills: home.join("skills"),
62            home,
63        }
64    }
65
66    /// Default — uses `$OXICODE_HOME` or `$HOME/.oxicode`.
67    pub fn default_paths() -> Result<Self> {
68        oxicode_sdk::fs::home_dir()
69            .map(Self::from_home)
70            .context("could not resolve oxicode home directory")
71    }
72}
73
74/// Build an `Oxicode` engine wired with file-based port implementations.
75///
76/// This is the **composition root** for oxicode-cli. The catalog port
77/// performs network I/O during `init()`. Errors there fall back to
78/// a noop catalog so the user can re-run `oxicode refresh` later.
79///
80/// `hook_runner` registers the user's configured [`HookRunner`](oxicode_sdk::ports::HookRunner)
81/// (global + approved-project `[[hooks]]`) on the SDK's port registry. Pass
82/// `None` to keep the noop runner (default).
83pub async fn build_oxicode(
84    paths: &OxicodePaths,
85    embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
86    hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
87) -> Result<Oxicode> {
88    build_oxicode_with_catalog(
89        paths,
90        build_catalog_config(paths),
91        embedding_provider,
92        hook_runner,
93    )
94    .await
95}
96
97/// Build an `Oxicode` engine with a custom catalog config. Useful for
98/// tests (e.g. pointing the catalog at a tempdir).
99///
100/// `hook_runner` follows the same semantics as [`build_oxicode`]: `Some`
101/// installs the runner on the SDK's port registry, `None` keeps the noop
102/// default.
103pub async fn build_oxicode_with_catalog(
104    paths: &OxicodePaths,
105    catalog_config: CatalogConfig,
106    embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
107    hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
108) -> Result<Oxicode> {
109    ensure_parent(&paths.auth)?;
110    ensure_parent(&paths.config)?;
111    ensure_parent(&paths.sessions)?;
112
113    let catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog> =
114        match FileModelCatalog::init(catalog_config).await {
115            Ok(c) => c,
116            Err(e) => {
117                tracing::warn!(error = %e, "catalog init failed; continuing with noop");
118                oxicode_sdk::NoopModelCatalog::new()
119            }
120        };
121
122    let skill_loader = Arc::new(FileSkillLoader::single(&paths.skills));
123    let rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry> =
124        Arc::new(oxicode_sdk::ports::NoopRuleRegistry);
125    let agent_artifact_store = crate::internal_urls::agent_handler::AgentArtifactStore::new();
126    let local_root = paths.home.join("local-artifacts");
127
128    let mut builder = oxicode_sdk::OxicodeBuilder::new()
129        .with_builtins()
130        .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
131        .with_auth(crate::store::auth_storage::shared_auth_storage())
132        .with_config(Arc::new(FileConfigStore::new(&paths.config)))
133        .with_skills(skill_loader.clone())
134        .with_personas(Arc::new(FilePersonaProvider::new(
135            paths.home.join("personas"),
136        )))
137        .with_access(Arc::new(SimpleAccessGate::from_file(
138            paths.home.join("access.toml"),
139        )))
140        .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
141            paths.home.join("capabilities.toml"),
142        )))
143        .with_event_bus(InProcessEventBus::new(64))
144        .with_memory(Arc::new(InMemoryMemoryStore::new()))
145        .with_cron(Arc::new(InMemoryCronScheduler::new()))
146        .with_resources(Arc::new(CountingResourceMonitor::new()))
147        .with_catalog(catalog)
148        .with_url_router(build_url_router(
149            paths,
150            skill_loader,
151            rule_registry,
152            agent_artifact_store,
153            local_root,
154        ));
155
156    if let Some(ep) = embedding_provider {
157        builder = builder.with_embeddings(ep);
158    }
159
160    if let Some(runner) = hook_runner {
161        builder = builder.with_hooks(runner);
162    }
163
164    let oxicode = builder.build();
165
166    Ok(oxicode)
167}
168fn build_url_router(
169    paths: &OxicodePaths,
170    skill_loader: Arc<dyn oxicode_sdk::ports::SkillLoader>,
171    rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry>,
172    agent_store: crate::internal_urls::agent_handler::AgentArtifactStore,
173    local_root: PathBuf,
174) -> Arc<dyn InternalUrlRouter> {
175    let memory_root = paths.home.join("memory");
176    let router = CompositeUrlRouter::new();
177    router.register(Arc::new(MemoryProtocolHandler::new(memory_root)));
178    router.register(Arc::new(IssueProtocolHandler));
179    router.register(Arc::new(PrProtocolHandler));
180    router.register(Arc::new(
181        crate::internal_urls::skill_handler::SkillProtocolHandler::new(skill_loader),
182    ));
183    router.register(Arc::new(
184        crate::internal_urls::rule_handler::RuleProtocolHandler::new(rule_registry),
185    ));
186    router.register(Arc::new(
187        crate::internal_urls::agent_handler::AgentProtocolHandler::new(agent_store),
188    ));
189    router.register(Arc::new(
190        crate::internal_urls::local_handler::LocalProtocolHandler::new(local_root),
191    ));
192    Arc::new(router)
193}
194
195/// Build a `CatalogConfig` rooted at `paths.home`.
196fn build_catalog_config(paths: &OxicodePaths) -> CatalogConfig {
197    CatalogConfig {
198        cache_path: paths.home.join("cache").join("models-dev.json"),
199        etag_path: paths.home.join("cache").join("models-dev.json.etag"),
200        override_path: paths.home.join("catalog").join("overrides.toml"),
201        mtime_window: std::time::Duration::from_secs(60 * 60),
202        fetch_enabled: std::env::var("OXICODE_MODELS_DEV_DISABLE_FETCH")
203            .ok()
204            .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
205            .unwrap_or(true),
206        models_dev_url: std::env::var("OXICODE_MODELS_DEV_URL")
207            .unwrap_or_else(|_| "https://models.dev".to_string()),
208        user_agent: format!("oxicode-cli/{}", env!("CARGO_PKG_VERSION")),
209        local_discovery_urls: local_discovery_from_env(),
210        snapshot_path: paths.home.join("cache").join("models-dev.json"),
211    }
212}
213
214/// Resolve local-discovery URLs from environment.
215///
216/// `OXICODE_LOCAL_DISCOVERY` is a comma-separated list of base URLs.
217fn local_discovery_from_env() -> Vec<String> {
218    std::env::var("OXICODE_LOCAL_DISCOVERY")
219        .ok()
220        .map(|s| {
221            s.split(',')
222                .map(|u| u.trim().to_string())
223                .filter(|u| !u.is_empty())
224                .collect()
225        })
226        .unwrap_or_default()
227}
228
229/// Spawn a background task that drains the catalog event channel and
230/// logs at info level.
231pub fn spawn_catalog_event_logger(
232    catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>,
233) -> tokio::task::JoinHandle<()> {
234    let mut rx = catalog.subscribe();
235    tokio::spawn(async move {
236        while let Ok(event) = rx.recv().await {
237            match event {
238                CatalogEvent::Updated {
239                    provider_count,
240                    model_count,
241                } => {
242                    tracing::info!(provider_count, model_count, "catalog refreshed");
243                }
244                CatalogEvent::RefreshFailed { reason, .. } => {
245                    tracing::warn!(reason, "catalog refresh failed");
246                }
247                CatalogEvent::OverrideApplied {
248                    path,
249                    provider_overrides,
250                    model_overrides,
251                } => {
252                    tracing::info!(
253                        path = %path.display(),
254                        provider_overrides,
255                        model_overrides,
256                        "catalog overrides applied"
257                    );
258                }
259                CatalogEvent::LocalDiscovered {
260                    base_url,
261                    model_count,
262                } => {
263                    tracing::info!(base_url, model_count, "local models discovered");
264                }
265            }
266        }
267    })
268}
269
270fn ensure_parent(path: &Path) -> Result<()> {
271    if let Some(parent) = path.parent() {
272        std::fs::create_dir_all(parent)
273            .with_context(|| format!("create_dir_all {}", parent.display()))?;
274    }
275    Ok(())
276}
277
278// ── Memory embedding provider (Hindsight ④ + Gap-1 wiring) ────
279
280/// Build the embedding provider configured by the user.
281///
282/// Returns `None` when `settings.embedding_provider == "none"`,
283/// when base URL / API key are missing, or when the remote provider
284/// fails to construct. Failures are non-fatal.
285pub fn build_embedding_provider(
286    settings: &crate::store::settings::Settings,
287) -> Option<Arc<dyn oxicode_mnemopi::EmbeddingProvider>> {
288    match settings.embedding_provider.as_str() {
289        "remote" => build_remote_embedding_provider(settings),
290        _ => None,
291    }
292}
293
294/// Construct a `RemoteEmbeddingProvider` from settings.
295fn build_remote_embedding_provider(
296    settings: &crate::store::settings::Settings,
297) -> Option<Arc<dyn oxicode_mnemopi::EmbeddingProvider>> {
298    let base_url = settings.embedding_base_url.as_deref()?.trim();
299    if base_url.is_empty() {
300        tracing::warn!("memory: embedding_provider='remote' but embedding_base_url is empty");
301        return None;
302    }
303    let api_key = std::env::var(&settings.embedding_api_key_env).ok()?;
304    if api_key.is_empty() {
305        tracing::warn!(
306            "memory: embedding_provider='remote' but env var {} is unset",
307            settings.embedding_api_key_env
308        );
309        return None;
310    }
311    let model = if settings.embedding_model.is_empty() {
312        "text-embedding-3-small".to_string()
313    } else {
314        settings.embedding_model.clone()
315    };
316    Some(Arc::new(oxicode_mnemopi::RemoteEmbeddingProvider::new(
317        base_url, &api_key, &model,
318    )))
319}
320
321// ── Embedding port bridge (mnemopi → SDK async port) ──────────────────
322
323/// Bridges oxicode-mnemopi's synchronous [`oxicode_mnemopi::EmbeddingProvider`] to the SDK's
324/// async [`oxicode_sdk::ports::EmbeddingProvider`] port trait. Each `embed()`
325/// call runs on the blocking thread pool via `spawn_blocking`.
326pub struct MnemopiEmbeddingBridge {
327    inner: Arc<dyn oxicode_mnemopi::EmbeddingProvider>,
328}
329
330impl MnemopiEmbeddingBridge {
331    /// Wrap a mnemopi embedding provider into the SDK port trait.
332    pub fn new(inner: Arc<dyn oxicode_mnemopi::EmbeddingProvider>) -> Self {
333        Self { inner }
334    }
335}
336
337impl oxicode_sdk::ports::EmbeddingProvider for MnemopiEmbeddingBridge {
338    fn embed<'a>(
339        &'a self,
340        text: &'a str,
341    ) -> Pin<Box<dyn Future<Output = Result<Vec<f32>, oxicode_sdk::SdkError>> + Send + 'a>> {
342        Box::pin(async move {
343            let inner = Arc::clone(&self.inner);
344            let text = text.to_string();
345            let result = tokio::task::spawn_blocking(move || inner.embed(&[text]))
346                .await
347                .map_err(|e| {
348                    oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding task panicked: {e}"))
349                })?;
350            let mut vectors = result.map_err(|e| {
351                oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding failed: {e}"))
352            })?;
353            vectors.pop().ok_or_else(|| {
354                oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding returned no vectors"))
355            })
356        })
357    }
358}
359
360// ── Memory backend helpers (Hindsight ④) ──────────────────────────────
361
362/// Create a memory backend if memory is enabled in settings.
363///
364/// Returns `None` when `memory_enabled` is false or the database
365/// cannot be opened.
366pub fn create_memory_backend(
367    settings: &crate::store::settings::Settings,
368) -> Option<Arc<dyn oxicode_agent::tools::MemoryBackend>> {
369    if !settings.memory_enabled {
370        return None;
371    }
372    let db_path = settings.memory_db_path.clone().unwrap_or_else(|| {
373        dirs::home_dir()
374            .unwrap_or_default()
375            .join(".oxicode")
376            .join("memory")
377            .join("project.db")
378    });
379    // Ensure the parent directory exists.
380    if let Some(parent) = db_path.parent() {
381        let _ = std::fs::create_dir_all(parent);
382    }
383    if settings.mnemopi_engine {
384        let embedding_provider = build_embedding_provider(settings);
385        let embedding_model = settings.embedding_model.clone();
386        match crate::store::memory_mnemopi::MnemopiMemoryBackend::open(
387            &db_path,
388            "default",
389            embedding_provider,
390            &embedding_model,
391        ) {
392            Ok(store) => Some(Arc::new(store)),
393            Err(e) => {
394                tracing::warn!(
395                    "Failed to open Mnemopi engine at {}: {e}",
396                    db_path.display()
397                );
398                None
399            }
400        }
401    } else {
402        match crate::store::memory_sqlite::SqliteMemoryStore::open(&db_path) {
403            Ok(store) => Some(Arc::new(store)),
404            Err(e) => {
405                tracing::warn!(
406                    "Failed to open memory database at {}: {e}",
407                    db_path.display()
408                );
409                None
410            }
411        }
412    }
413}
414
415/// Wrap a memory backend with the LLM/heuristic fact extractor.
416pub fn wrap_extracting(
417    backend: Arc<dyn oxicode_agent::tools::MemoryBackend>,
418    settings: &crate::store::settings::Settings,
419    oxicode: Option<&oxicode_sdk::Oxicode>,
420) -> Arc<dyn oxicode_agent::tools::MemoryBackend> {
421    extracting_backend::wrap_with_extractor(backend, settings, oxicode)
422}
423
424/// Build a project-memory recall block for injection into the system
425/// prompt. Returns an empty string when no memories exist.
426pub async fn build_memory_recall(
427    backend: &dyn oxicode_agent::tools::MemoryBackend,
428    subject: &str,
429) -> String {
430    match backend.list(subject).await {
431        Ok(items) if !items.is_empty() => {
432            let mut block = String::from(
433                "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
434            );
435            for item in &items {
436                block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
437            }
438            block
439        }
440        _ => String::new(),
441    }
442}
443
444/// Build the autonomous-memory read-path block (omp `read-path.md`)
445/// by reading `<memory-root>/memory_summary.md` if it exists.
446pub fn read_path_block(home: &Path, cwd: &Path) -> Option<String> {
447    let cwd_str = cwd.to_string_lossy().to_string();
448    let memory_root = memory_summary::memory_root(home, &cwd_str);
449    let (_, memory_summary_text) =
450        memory_summary::load_consolidated_artifacts(&memory_root).ok()?;
451    let summary = memory_summary_text?;
452    Some(memory_summary::render_read_path(Some(&summary), None))
453}
454
455/// Store a session summary into the memory backend.
456///
457/// **NOTE: currently uncalled** — defined as a future hook point for
458/// session-end reflection. Nothing wires it to session lifecycle yet.
459/// See FINAL-ROADMAP.md §알려진 갭 (⑨ mental-models).
460pub async fn session_reflect(
461    backend: &dyn oxicode_agent::tools::MemoryBackend,
462    subject: &str,
463    summary: &str,
464) {
465    if let Err(e) = backend.put(summary, "summary", subject).await {
466        tracing::warn!("Failed to store session memory: {e}");
467    }
468}
469
470/// Open (or create) the autonomous-memory pipeline DB and spawn the
471/// background Phase-1 / Phase-2 workers. Returns `None` when the
472/// pipeline is disabled (default).
473///
474/// When `oxicode` is `Some`, the pipeline resolves a memory extraction
475/// model from settings and creates a provider for actual LLM calls.
476/// Without it, workers run but skip LLM-dependent work.
477pub fn start_memory_pipeline(
478    settings: &crate::store::settings::Settings,
479    cwd: &Path,
480    oxicode: Option<&oxicode_sdk::Oxicode>,
481) -> Option<tokio::task::JoinHandle<()>> {
482    let backend = settings.memory_backend.as_deref().unwrap_or("off");
483    if backend != "local" {
484        tracing::debug!("autonomous memory pipeline: backend='{backend}' — disabled");
485        return None;
486    }
487
488    let home = crate::store::settings::Settings::settings_dir().ok()?;
489    let db_path = memory_workers::pipeline_db_path(&home);
490    let sessions_dir = home.join("sessions");
491
492    let cwd_str = cwd.to_string_lossy().to_string();
493    let memory_root = memory_summary::memory_root(&home, &cwd_str);
494
495    // Resolve memory extraction model + provider from the Oxicode engine.
496    let (provider, model) = if let Some(oxicode) = oxicode {
497        let model_id = if settings.memory_llm_extract_model.is_empty() {
498            settings.effective_model(None).unwrap_or_default()
499        } else {
500            settings.memory_llm_extract_model.clone()
501        };
502        if model_id.is_empty() {
503            tracing::warn!("memory pipeline: no model configured for extraction");
504            (None, None)
505        } else {
506            match oxicode.resolve_model(&model_id) {
507                Ok(model) => match oxicode.create_provider(&model.provider) {
508                    Ok(provider) => (Some(provider), Some(model)),
509                    Err(e) => {
510                        tracing::warn!("memory pipeline: provider creation failed: {e}");
511                        (None, None)
512                    }
513                },
514                Err(e) => {
515                    tracing::warn!("memory pipeline: model resolution failed: {e}");
516                    (None, None)
517                }
518            }
519        }
520    } else {
521        tracing::warn!("memory pipeline: no Oxicode engine, LLM calls will be skipped");
522        (None, None)
523    };
524
525    let poll_interval = std::time::Duration::from_secs(60);
526
527    let handle = tokio::task::spawn_blocking(move || {
528        let rt = tokio::runtime::Builder::new_current_thread()
529            .enable_all()
530            .build()
531            .expect("memory pipeline runtime");
532        rt.block_on(async move {
533            let conn = match memory_workers::open_db(&db_path) {
534                Ok(c) => c,
535                Err(e) => {
536                    tracing::warn!(
537                        "autonomous memory pipeline: open_db({}) failed: {e}",
538                        db_path.display()
539                    );
540                    return;
541                }
542            };
543
544            tracing::info!(
545                "autonomous memory pipeline: workers started (provider={})",
546                if provider.is_some() { "wired" } else { "none" }
547            );
548
549            loop {
550                let now = chrono::Utc::now().timestamp();
551
552                match memory_workers::run_stage1_iteration(
553                    &conn,
554                    &sessions_dir,
555                    &cwd_str,
556                    now,
557                    provider.as_ref(),
558                    model.as_ref(),
559                )
560                .await
561                {
562                    Ok(true) => tracing::debug!("memory pipeline: stage 1 processed a job"),
563                    Ok(false) => tracing::trace!("memory pipeline: stage 1 idle"),
564                    Err(e) => tracing::warn!("memory pipeline: stage 1 error: {e}"),
565                }
566
567                match memory_workers::run_stage2_iteration(
568                    &conn,
569                    &memory_root,
570                    &cwd_str,
571                    now,
572                    provider.as_ref(),
573                    model.as_ref(),
574                )
575                .await
576                {
577                    Ok(true) => tracing::info!("memory pipeline: stage 2 consolidated"),
578                    Ok(false) => tracing::trace!("memory pipeline: stage 2 idle"),
579                    Err(e) => tracing::warn!("memory pipeline: stage 2 error: {e}"),
580                }
581
582                tokio::time::sleep(poll_interval).await;
583            }
584        });
585    });
586    Some(handle)
587}
588
589#[cfg(test)]
590mod tests {
591
592    use super::*;
593
594    #[test]
595    fn paths_are_consistent() {
596        let p = OxicodePaths::from_home("/tmp/oxicode-test");
597        assert!(p.auth.starts_with("/tmp/oxicode-test"));
598        assert!(p.config.starts_with("/tmp/oxicode-test"));
599        assert!(p.sessions.starts_with("/tmp/oxicode-test"));
600        assert!(p.skills.starts_with("/tmp/oxicode-test"));
601    }
602
603    #[tokio::test]
604    async fn build_oxicode_succeeds() {
605        let tmp = tempfile::TempDir::new().unwrap();
606        let paths = OxicodePaths::from_home(tmp.path());
607        let oxicode = build_oxicode(&paths, None, None).await.unwrap();
608        let _ = oxicode.ports().state;
609    }
610}