1use 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;
34
35#[derive(Debug, Clone)]
37pub struct OxicodePaths {
38 pub home: PathBuf,
40 pub auth: PathBuf,
42 pub config: PathBuf,
44 pub sessions: PathBuf,
46 pub skills: PathBuf,
48 pub foundation: Option<PathBuf>,
53}
54
55impl OxicodePaths {
56 pub fn from_home(home: impl Into<PathBuf>) -> Self {
58 let home = home.into();
59 Self {
60 auth: home.join("auth.json"),
61 config: home.join("settings.toml"),
62 sessions: home.join("sessions"),
63 skills: home.join("skills"),
64 home,
65 foundation: crate::foundation::foundation_root(),
66 }
67 }
68
69 pub fn default_paths() -> Result<Self> {
71 oxicode_sdk::fs::home_dir()
72 .map(Self::from_home)
73 .context("could not resolve oxicode home directory")
74 }
75}
76
77pub async fn build_oxicode(
87 paths: &OxicodePaths,
88 embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
89 hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
90) -> Result<Oxicode> {
91 build_oxicode_with_catalog(
92 paths,
93 build_catalog_config(paths),
94 embedding_provider,
95 hook_runner,
96 )
97 .await
98}
99
100pub async fn build_oxicode_with_catalog(
107 paths: &OxicodePaths,
108 catalog_config: CatalogConfig,
109 embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
110 hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
111) -> Result<Oxicode> {
112 ensure_parent(&paths.auth)?;
113 ensure_parent(&paths.config)?;
114 ensure_parent(&paths.sessions)?;
115
116 let foundation_provider: Option<Arc<dyn oxicode_ai::Provider>> = if let Some(froot) = paths
126 .foundation
127 .clone()
128 .or_else(crate::foundation::foundation_root)
129 {
130 if crate::foundation::foundation_present(&froot) {
131 match resolve_and_register_profile(&froot).await {
132 Ok(p) => Some(p),
133 Err(e) => {
134 tracing::warn!(
135 "Foundation v1 profile resolution failed: {e}; \
136 engine will start without a registered provider"
137 );
138 None
139 }
140 }
141 } else {
142 None
143 }
144 } else {
145 None
146 };
147
148 let catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog> =
149 match FileModelCatalog::init(catalog_config).await {
150 Ok(c) => c,
151 Err(e) => {
152 tracing::warn!(error = %e, "catalog init failed; continuing with noop");
153 oxicode_sdk::NoopModelCatalog::new()
154 }
155 };
156
157 let skill_loader = Arc::new(FileSkillLoader::single(&paths.skills));
158 let rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry> =
159 Arc::new(oxicode_sdk::ports::NoopRuleRegistry);
160 let agent_artifact_store = crate::internal_urls::agent_handler::AgentArtifactStore::new();
161 let local_root = paths.home.join("local-artifacts");
162
163 let mut builder = oxicode_sdk::OxicodeBuilder::new()
164 .with_builtins()
165 .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
166 .with_auth(crate::store::auth_storage::shared_auth_storage())
167 .with_config(Arc::new(FileConfigStore::new(&paths.config)))
168 .with_skills(skill_loader.clone())
169 .with_personas(Arc::new(FilePersonaProvider::new(
170 paths.home.join("personas"),
171 )))
172 .with_access(Arc::new(SimpleAccessGate::from_file(
173 paths.home.join("access.toml"),
174 )))
175 .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
176 paths.home.join("capabilities.toml"),
177 )))
178 .with_event_bus(InProcessEventBus::new(64))
179 .with_memory(Arc::new(InMemoryMemoryStore::new()))
180 .with_cron(Arc::new(InMemoryCronScheduler::new()))
181 .with_resources(Arc::new(CountingResourceMonitor::new()))
182 .with_catalog(catalog)
183 .with_url_router(build_url_router(
184 paths,
185 skill_loader,
186 rule_registry,
187 agent_artifact_store,
188 local_root,
189 ));
190
191 if let Some(ep) = embedding_provider {
192 builder = builder.with_embeddings(ep);
193 }
194 if let Some(runner) = hook_runner {
195 builder = builder.with_hooks(runner.clone());
196 }
197 if let Some(provider) = foundation_provider {
198 builder = builder.provider_arc("<foundation>", provider);
199 }
200
201 let oxicode = builder.build();
202
203 Ok(oxicode)
204}
205fn build_url_router(
206 paths: &OxicodePaths,
207 skill_loader: Arc<dyn oxicode_sdk::ports::SkillLoader>,
208 rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry>,
209 agent_store: crate::internal_urls::agent_handler::AgentArtifactStore,
210 local_root: PathBuf,
211) -> Arc<dyn InternalUrlRouter> {
212 let memory_root = paths.home.join("memory");
213 let router = CompositeUrlRouter::new();
214 let handler: Arc<dyn oxicode_sdk::ports::ProtocolHandler> =
221 if crate::foundation::foundation_present(
222 &crate::foundation::foundation_root()
223 .unwrap_or_else(|| std::path::PathBuf::from("~/.oxi/foundation/v1")),
224 ) {
225 let socket = crate::foundation::brain::default_socket_path();
226 let brain = Arc::new(crate::foundation::brain::BrainMemoryBackend::new(socket));
227 Arc::new(MemoryProtocolHandler::new(brain))
228 } else {
229 struct LegacyHandler {
234 memory_root: PathBuf,
235 }
236 #[async_trait::async_trait]
237 impl oxicode_sdk::ports::ProtocolHandler for LegacyHandler {
238 fn scheme(&self) -> &str {
239 "memory"
240 }
241 async fn resolve(
242 &self,
243 url: &str,
244 _selector: Option<&str>,
245 _ctx: &oxicode_sdk::ports::ResolveContext,
246 ) -> Result<oxicode_sdk::ports::ResolvedUrl, oxicode_sdk::SdkError>
247 {
248 let content = crate::internal_urls::memory_handler::resolve_memory_url_legacy(
249 url,
250 &self.memory_root,
251 )
252 .ok_or_else(|| oxicode_sdk::SdkError::PortNotConfigured { port: "memory" })?;
253 let size = content.len();
254 Ok(oxicode_sdk::ports::ResolvedUrl {
255 url: url.to_string(),
256 content,
257 content_type: "text/markdown".to_string(),
258 size: Some(size),
259 source_path: None,
260 notes: vec![],
261 immutable: true,
262 })
263 }
264 }
265 Arc::new(LegacyHandler { memory_root })
266 };
267 router.register(handler);
268 router.register(Arc::new(IssueProtocolHandler));
269 router.register(Arc::new(PrProtocolHandler));
270 router.register(Arc::new(
271 crate::internal_urls::skill_handler::SkillProtocolHandler::new(skill_loader),
272 ));
273 router.register(Arc::new(
274 crate::internal_urls::rule_handler::RuleProtocolHandler::new(rule_registry),
275 ));
276 router.register(Arc::new(
277 crate::internal_urls::agent_handler::AgentProtocolHandler::new(agent_store),
278 ));
279 router.register(Arc::new(
280 crate::internal_urls::local_handler::LocalProtocolHandler::new(local_root),
281 ));
282 Arc::new(router)
283}
284
285fn build_catalog_config(paths: &OxicodePaths) -> CatalogConfig {
287 CatalogConfig {
288 cache_path: paths.home.join("cache").join("models-dev.json"),
289 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
290 override_path: paths.home.join("catalog").join("overrides.toml"),
291 mtime_window: std::time::Duration::from_secs(60 * 60),
292 fetch_enabled: std::env::var("OXICODE_MODELS_DEV_DISABLE_FETCH")
293 .ok()
294 .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
295 .unwrap_or(true),
296 models_dev_url: std::env::var("OXICODE_MODELS_DEV_URL")
297 .unwrap_or_else(|_| "https://models.dev".to_string()),
298 user_agent: format!("oxicode-cli/{}", env!("CARGO_PKG_VERSION")),
299 local_discovery_urls: local_discovery_from_env(),
300 snapshot_path: paths.home.join("cache").join("models-dev.json"),
301 }
302}
303
304fn local_discovery_from_env() -> Vec<String> {
308 std::env::var("OXICODE_LOCAL_DISCOVERY")
309 .ok()
310 .map(|s| {
311 s.split(',')
312 .map(|u| u.trim().to_string())
313 .filter(|u| !u.is_empty())
314 .collect()
315 })
316 .unwrap_or_default()
317}
318
319pub fn spawn_catalog_event_logger(
322 catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>,
323) -> tokio::task::JoinHandle<()> {
324 let mut rx = catalog.subscribe();
325 tokio::spawn(async move {
326 while let Ok(event) = rx.recv().await {
327 match event {
328 CatalogEvent::Updated {
329 provider_count,
330 model_count,
331 } => {
332 tracing::info!(provider_count, model_count, "catalog refreshed");
333 }
334 CatalogEvent::RefreshFailed { reason, .. } => {
335 tracing::warn!(reason, "catalog refresh failed");
336 }
337 CatalogEvent::OverrideApplied {
338 path,
339 provider_overrides,
340 model_overrides,
341 } => {
342 tracing::info!(
343 path = %path.display(),
344 provider_overrides,
345 model_overrides,
346 "catalog overrides applied"
347 );
348 }
349 CatalogEvent::LocalDiscovered {
350 base_url,
351 model_count,
352 } => {
353 tracing::info!(base_url, model_count, "local models discovered");
354 }
355 }
356 }
357 })
358}
359
360fn ensure_parent(path: &Path) -> Result<()> {
361 if let Some(parent) = path.parent() {
362 std::fs::create_dir_all(parent)
363 .with_context(|| format!("create_dir_all {}", parent.display()))?;
364 }
365 Ok(())
366}
367
368pub fn build_embedding_provider(
376 settings: &crate::store::settings::Settings,
377) -> Option<Arc<dyn oxicode_mnemopi::EmbeddingProvider>> {
378 match settings.embedding_provider.as_str() {
379 "remote" => build_remote_embedding_provider(settings),
380 _ => None,
381 }
382}
383
384fn build_remote_embedding_provider(
386 settings: &crate::store::settings::Settings,
387) -> Option<Arc<dyn oxicode_mnemopi::EmbeddingProvider>> {
388 let base_url = settings.embedding_base_url.as_deref()?.trim();
389 if base_url.is_empty() {
390 tracing::warn!("memory: embedding_provider='remote' but embedding_base_url is empty");
391 return None;
392 }
393 let api_key = std::env::var(&settings.embedding_api_key_env).ok()?;
394 if api_key.is_empty() {
395 tracing::warn!(
396 "memory: embedding_provider='remote' but env var {} is unset",
397 settings.embedding_api_key_env
398 );
399 return None;
400 }
401 let model = if settings.embedding_model.is_empty() {
402 "text-embedding-3-small".to_string()
403 } else {
404 settings.embedding_model.clone()
405 };
406 Some(Arc::new(oxicode_mnemopi::RemoteEmbeddingProvider::new(
407 base_url, &api_key, &model,
408 )))
409}
410
411pub struct MnemopiEmbeddingBridge {
417 inner: Arc<dyn oxicode_mnemopi::EmbeddingProvider>,
418}
419
420impl MnemopiEmbeddingBridge {
421 pub fn new(inner: Arc<dyn oxicode_mnemopi::EmbeddingProvider>) -> Self {
423 Self { inner }
424 }
425}
426
427impl oxicode_sdk::ports::EmbeddingProvider for MnemopiEmbeddingBridge {
428 fn embed<'a>(
429 &'a self,
430 text: &'a str,
431 ) -> Pin<Box<dyn Future<Output = Result<Vec<f32>, oxicode_sdk::SdkError>> + Send + 'a>> {
432 Box::pin(async move {
433 let inner = Arc::clone(&self.inner);
434 let text = text.to_string();
435 let result = tokio::task::spawn_blocking(move || inner.embed(&[text]))
436 .await
437 .map_err(|e| {
438 oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding task panicked: {e}"))
439 })?;
440 let mut vectors = result.map_err(|e| {
441 oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding failed: {e}"))
442 })?;
443 vectors.pop().ok_or_else(|| {
444 oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding returned no vectors"))
445 })
446 })
447 }
448}
449
450pub fn create_memory_backend(
467 settings: &crate::store::settings::Settings,
468) -> Option<Arc<dyn oxicode_agent::tools::MemoryBackend>> {
469 if !settings.memory_enabled {
470 return None;
471 }
472 let foundation_root = crate::foundation::foundation_root()
473 .unwrap_or_else(|| std::path::PathBuf::from("~/.oxi/foundation/v1"));
474 if crate::foundation::foundation_present(&foundation_root) {
475 let socket = crate::foundation::brain::default_socket_path();
476 let backend = crate::foundation::brain::BrainMemoryBackend::new(socket);
477 tracing::info!(
478 "Foundation v1 host active: durable memory authority is oxibrain \
479 (health: {})",
480 backend.health().info()
481 );
482 return Some(Arc::new(backend));
483 }
484 tracing::warn!(
485 "Foundation v1 host: oxibrain daemon unavailable; durable-memory \
486 tools will return typed unavailable results. Run `oxicode setup` to \
487 initialize the Foundation installation, or start the oxibrain daemon."
488 );
489 None
490}
491
492#[cfg(test)]
493mod memory_backend_tests {
494 use super::*;
495
496 #[test]
497 fn brain_backend_returned_when_foundation_present() {
498 let tmp = tempdir_fixture();
499 unsafe {
500 std::env::set_var("OXI_FOUNDATION_HOME", &tmp);
501 }
502 std::fs::write(
505 tmp.join("foundation.json"),
506 r#"{"schema_version":1,"foundation":{"hosts":{"oxicode":">=0.1.0"}}}"#,
507 )
508 .unwrap();
509 std::fs::write(
510 tmp.join("profiles.json"),
511 r#"{"schema_version":1,"profiles":[]}"#,
512 )
513 .unwrap();
514 let backend = create_memory_backend(&test_settings());
515 assert!(backend.is_some(), "foundation fixture ⇒ brain backend");
516 unsafe {
517 std::env::remove_var("OXI_FOUNDATION_HOME");
518 }
519 }
520
521 #[test]
522 fn absent_foundation_returns_none() {
523 unsafe {
524 std::env::set_var("OXI_FOUNDATION_HOME", "/tmp/does-not-exist-foundation");
525 }
526 let backend = create_memory_backend(&test_settings());
527 assert!(
528 backend.is_none(),
529 "absent foundation ⇒ no local durable fallback (plan §5.h)"
530 );
531 unsafe {
532 std::env::remove_var("OXI_FOUNDATION_HOME");
533 }
534 }
535
536 fn test_settings() -> crate::store::settings::Settings {
537 let mut s = crate::store::settings::Settings::default();
538 s.memory_enabled = true;
539 s
540 }
541
542 fn tempdir_fixture() -> std::path::PathBuf {
543 let dir = std::env::temp_dir().join(format!(
544 "oxicode-services-test-{}",
545 std::time::SystemTime::now()
546 .duration_since(std::time::UNIX_EPOCH)
547 .unwrap()
548 .as_nanos()
549 ));
550 std::fs::create_dir_all(&dir).unwrap();
551 dir
552 }
553}
554
555pub async fn build_memory_recall(
558 backend: &dyn oxicode_agent::tools::MemoryBackend,
559 subject: &str,
560) -> String {
561 match backend.list(subject).await {
562 Ok(items) if !items.is_empty() => {
563 let mut block = String::from(
564 "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
565 );
566 for item in &items {
567 block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
568 }
569 block
570 }
571 _ => String::new(),
572 }
573}
574
575pub async fn session_reflect(
581 backend: &dyn oxicode_agent::tools::MemoryBackend,
582 subject: &str,
583 summary: &str,
584) {
585 if let Err(e) = backend.put(summary, "summary", subject).await {
586 tracing::warn!("Failed to store session memory: {e}");
587 }
588}
589
590pub fn start_memory_pipeline(
601 _settings: &crate::store::settings::Settings,
602 _cwd: &Path,
603 _oxicode: Option<&oxicode_sdk::Oxicode>,
604) -> Option<tokio::task::JoinHandle<()>> {
605 None
606}
607
608async fn resolve_and_register_profile(
623 foundation_root: &Path,
624) -> Result<Arc<dyn oxicode_ai::Provider>, crate::foundation::FoundationError> {
625 use crate::foundation::profiles::{
626 EnvironmentOverride, ResolveInput, read as read_profiles, resolve_profile,
627 };
628
629 let profiles_path = foundation_root.join(crate::foundation::files::PROFILES);
630 let profiles = read_profiles(&profiles_path)?;
631 let explicit_profile = std::env::var("OXICODE_PROFILE")
632 .ok()
633 .filter(|s| !s.trim().is_empty());
634 let compat_import_path = foundation_root.join("compatibility.json");
635 let compat_import =
636 crate::foundation::compat_import::read_compatibility_shim(&compat_import_path)?;
637
638 let env_override = EnvironmentOverride::from_env();
639
640 let resolved = resolve_profile(ResolveInput {
641 explicit_profile: explicit_profile.as_deref(),
642 explicit_environment_override: env_override.as_ref(),
643 requested_role: None,
644 foundation_profiles: &profiles,
645 compatibility_import: compat_import.as_ref(),
646 })?;
647
648 let resolver = crate::foundation::credentials::KeychainCredentialResolver::default();
649 let credential = resolver.resolve(&resolved.profile);
650 let api_key = match credential {
651 crate::foundation::credentials::Credential::Keychain(s)
652 | crate::foundation::credentials::Credential::Environment(s) => s,
653 crate::foundation::credentials::Credential::Unavailable(e) => {
654 return Err(crate::foundation::FoundationError::KeychainUnavailable(
655 e.to_string(),
656 ));
657 }
658 };
659 let provider_name = resolved.profile.provider.as_str();
660 let provider: Arc<dyn oxicode_ai::Provider> = Arc::from(
661 oxicode_ai::register_builtins::create_builtin_provider_with_options(
662 provider_name,
663 Some(&api_key),
664 None,
665 )
666 .ok_or_else(|| {
667 crate::foundation::FoundationError::IncompatibleHost(provider_name.to_string())
668 })?,
669 );
670
671 tracing::info!(
672 provider = provider_name,
673 model = %resolved.profile.model,
674 source = ?resolved.source,
675 "Foundation profile resolved with Keychain credential"
676 );
677 Ok(provider)
678}
679#[cfg(test)]
680mod tests {
681
682 use super::*;
683
684 #[test]
685 fn paths_are_consistent() {
686 let p = OxicodePaths::from_home("/tmp/oxicode-test");
687 assert!(p.auth.starts_with("/tmp/oxicode-test"));
688 assert!(p.config.starts_with("/tmp/oxicode-test"));
689 assert!(p.sessions.starts_with("/tmp/oxicode-test"));
690 assert!(p.skills.starts_with("/tmp/oxicode-test"));
691 }
692
693 #[tokio::test]
694 async fn build_oxicode_succeeds() {
695 let tmp = tempfile::TempDir::new().unwrap();
696 let paths = OxicodePaths::from_home(tmp.path());
697 let oxicode = build_oxicode(&paths, None, None).await.unwrap();
698 let _ = oxicode.ports().state;
699 }
700}