1use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use anyhow::{Context, Result};
16
17use oxicode_sdk::Oxicode;
18use oxicode_sdk::fs::{
19 FileConfigStore, FileModelCatalog, FilePersonaProvider, FileSkillLoader, FileStateStore,
20 SimpleAccessGate, TomlCapabilityResolver,
21};
22use oxicode_sdk::inmem::{
23 CountingResourceMonitor, InMemoryCronScheduler, InMemoryMemoryStore, InProcessEventBus,
24};
25use oxicode_sdk::ports::InternalUrlRouter;
26use oxicode_sdk::ports::catalog::CatalogEvent;
27use oxicode_sdk::ports::fs::CatalogConfig;
28use oxicode_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;
33
34#[derive(Debug, Clone)]
36pub struct OxicodePaths {
37 pub home: PathBuf,
39 pub auth: PathBuf,
41 pub config: PathBuf,
43 pub sessions: PathBuf,
45 pub skills: PathBuf,
47 pub foundation: Option<PathBuf>,
52}
53
54impl OxicodePaths {
55 pub fn from_home(home: impl Into<PathBuf>) -> Self {
57 let home = home.into();
58 Self {
59 auth: home.join("auth.json"),
60 config: home.join("settings.toml"),
61 sessions: home.join("sessions"),
62 skills: home.join("skills"),
63 home,
64 foundation: crate::foundation::foundation_root(),
65 }
66 }
67
68 pub fn default_paths() -> Result<Self> {
70 oxicode_sdk::fs::home_dir()
71 .map(Self::from_home)
72 .context("could not resolve oxicode home directory")
73 }
74}
75
76pub async fn build_oxicode(
86 paths: &OxicodePaths,
87 embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
88 hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
89) -> Result<Oxicode> {
90 build_oxicode_with_catalog(
91 paths,
92 build_catalog_config(paths),
93 embedding_provider,
94 hook_runner,
95 )
96 .await
97}
98
99pub async fn build_oxicode_with_catalog(
106 paths: &OxicodePaths,
107 catalog_config: CatalogConfig,
108 embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
109 hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
110) -> Result<Oxicode> {
111 ensure_parent(&paths.auth)?;
112 ensure_parent(&paths.config)?;
113 ensure_parent(&paths.sessions)?;
114
115 let foundation_provider: Option<Arc<dyn oxicode_ai::Provider>> = if let Some(froot) = paths
125 .foundation
126 .clone()
127 .or_else(crate::foundation::foundation_root)
128 {
129 if crate::foundation::foundation_present(&froot) {
130 match resolve_and_register_profile(&froot).await {
131 Ok(p) => Some(p),
132 Err(e) => {
133 tracing::warn!(
134 "Foundation v1 profile resolution failed: {e}; \
135 engine will start without a registered provider"
136 );
137 None
138 }
139 }
140 } else {
141 None
142 }
143 } else {
144 None
145 };
146
147 let catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog> =
148 match FileModelCatalog::init(catalog_config).await {
149 Ok(c) => c,
150 Err(e) => {
151 tracing::warn!(error = %e, "catalog init failed; continuing with noop");
152 oxicode_sdk::NoopModelCatalog::new()
153 }
154 };
155
156 let skill_loader = Arc::new(FileSkillLoader::single(&paths.skills));
157 let rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry> =
158 Arc::new(oxicode_sdk::ports::NoopRuleRegistry);
159 let agent_artifact_store = crate::internal_urls::agent_handler::AgentArtifactStore::new();
160 let local_root = paths.home.join("local-artifacts");
161
162 let mut builder = oxicode_sdk::OxicodeBuilder::new()
163 .with_builtins()
164 .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
165 .with_auth(crate::store::auth_storage::shared_auth_storage())
166 .with_config(Arc::new(FileConfigStore::new(&paths.config)))
167 .with_skills(skill_loader.clone())
168 .with_personas(Arc::new(FilePersonaProvider::new(
169 paths.home.join("personas"),
170 )))
171 .with_access(Arc::new(SimpleAccessGate::from_file(
172 paths.home.join("access.toml"),
173 )))
174 .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
175 paths.home.join("capabilities.toml"),
176 )))
177 .with_event_bus(InProcessEventBus::new(64))
178 .with_memory(Arc::new(InMemoryMemoryStore::new()))
179 .with_cron(Arc::new(InMemoryCronScheduler::new()))
180 .with_resources(Arc::new(CountingResourceMonitor::new()))
181 .with_catalog(catalog)
182 .with_url_router(build_url_router(
183 paths,
184 skill_loader,
185 rule_registry,
186 agent_artifact_store,
187 local_root,
188 ));
189
190 if let Some(ep) = embedding_provider {
191 builder = builder.with_embeddings(ep);
192 }
193 if let Some(runner) = hook_runner {
194 builder = builder.with_hooks(runner.clone());
195 }
196 if let Some(provider) = foundation_provider {
197 builder = builder.provider_arc("<foundation>", provider);
198 }
199
200 let oxicode = builder.build();
201
202 Ok(oxicode)
203}
204fn build_url_router(
205 paths: &OxicodePaths,
206 skill_loader: Arc<dyn oxicode_sdk::ports::SkillLoader>,
207 rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry>,
208 agent_store: crate::internal_urls::agent_handler::AgentArtifactStore,
209 local_root: PathBuf,
210) -> Arc<dyn InternalUrlRouter> {
211 let memory_root = paths.home.join("memory");
212 let router = CompositeUrlRouter::new();
213 let handler: Arc<dyn oxicode_sdk::ports::ProtocolHandler> =
220 if crate::foundation::foundation_present(
221 &crate::foundation::foundation_root()
222 .unwrap_or_else(|| std::path::PathBuf::from("~/.oxi/foundation/v1")),
223 ) {
224 let socket = crate::foundation::brain::default_socket_path();
225 let brain = Arc::new(crate::foundation::brain::BrainMemoryBackend::new(socket));
226 Arc::new(MemoryProtocolHandler::new(brain))
227 } else {
228 struct LegacyHandler {
233 memory_root: PathBuf,
234 }
235 #[async_trait::async_trait]
236 impl oxicode_sdk::ports::ProtocolHandler for LegacyHandler {
237 fn scheme(&self) -> &str {
238 "memory"
239 }
240 async fn resolve(
241 &self,
242 url: &str,
243 _selector: Option<&str>,
244 _ctx: &oxicode_sdk::ports::ResolveContext,
245 ) -> Result<oxicode_sdk::ports::ResolvedUrl, oxicode_sdk::SdkError>
246 {
247 let content = crate::internal_urls::memory_handler::resolve_memory_url_legacy(
248 url,
249 &self.memory_root,
250 )
251 .ok_or_else(|| oxicode_sdk::SdkError::PortNotConfigured { port: "memory" })?;
252 let size = content.len();
253 Ok(oxicode_sdk::ports::ResolvedUrl {
254 url: url.to_string(),
255 content,
256 content_type: "text/markdown".to_string(),
257 size: Some(size),
258 source_path: None,
259 notes: vec![],
260 immutable: true,
261 })
262 }
263 }
264 Arc::new(LegacyHandler { memory_root })
265 };
266 router.register(handler);
267 router.register(Arc::new(IssueProtocolHandler));
268 router.register(Arc::new(PrProtocolHandler));
269 router.register(Arc::new(
270 crate::internal_urls::skill_handler::SkillProtocolHandler::new(skill_loader),
271 ));
272 router.register(Arc::new(
273 crate::internal_urls::rule_handler::RuleProtocolHandler::new(rule_registry),
274 ));
275 router.register(Arc::new(
276 crate::internal_urls::agent_handler::AgentProtocolHandler::new(agent_store),
277 ));
278 router.register(Arc::new(
279 crate::internal_urls::local_handler::LocalProtocolHandler::new(local_root),
280 ));
281 Arc::new(router)
282}
283
284fn build_catalog_config(paths: &OxicodePaths) -> CatalogConfig {
286 CatalogConfig {
287 cache_path: paths.home.join("cache").join("models-dev.json"),
288 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
289 override_path: paths.home.join("catalog").join("overrides.toml"),
290 mtime_window: std::time::Duration::from_secs(60 * 60),
291 fetch_enabled: std::env::var("OXICODE_MODELS_DEV_DISABLE_FETCH")
292 .ok()
293 .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
294 .unwrap_or(true),
295 models_dev_url: std::env::var("OXICODE_MODELS_DEV_URL")
296 .unwrap_or_else(|_| "https://models.dev".to_string()),
297 user_agent: format!("oxicode-cli/{}", env!("CARGO_PKG_VERSION")),
298 local_discovery_urls: local_discovery_from_env(),
299 snapshot_path: paths.home.join("cache").join("models-dev.json"),
300 }
301}
302
303fn local_discovery_from_env() -> Vec<String> {
307 std::env::var("OXICODE_LOCAL_DISCOVERY")
308 .ok()
309 .map(|s| {
310 s.split(',')
311 .map(|u| u.trim().to_string())
312 .filter(|u| !u.is_empty())
313 .collect()
314 })
315 .unwrap_or_default()
316}
317
318pub fn spawn_catalog_event_logger(
321 catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>,
322) -> tokio::task::JoinHandle<()> {
323 let mut rx = catalog.subscribe();
324 tokio::spawn(async move {
325 while let Ok(event) = rx.recv().await {
326 match event {
327 CatalogEvent::Updated {
328 provider_count,
329 model_count,
330 } => {
331 tracing::info!(provider_count, model_count, "catalog refreshed");
332 }
333 CatalogEvent::RefreshFailed { reason, .. } => {
334 tracing::warn!(reason, "catalog refresh failed");
335 }
336 CatalogEvent::OverrideApplied {
337 path,
338 provider_overrides,
339 model_overrides,
340 } => {
341 tracing::info!(
342 path = %path.display(),
343 provider_overrides,
344 model_overrides,
345 "catalog overrides applied"
346 );
347 }
348 CatalogEvent::LocalDiscovered {
349 base_url,
350 model_count,
351 } => {
352 tracing::info!(base_url, model_count, "local models discovered");
353 }
354 }
355 }
356 })
357}
358
359fn ensure_parent(path: &Path) -> Result<()> {
360 if let Some(parent) = path.parent() {
361 std::fs::create_dir_all(parent)
362 .with_context(|| format!("create_dir_all {}", parent.display()))?;
363 }
364 Ok(())
365}
366
367pub(crate) fn brain_socket_present(path: &Path) -> bool {
374 #[cfg(unix)]
375 {
376 use std::os::unix::fs::FileTypeExt;
377 std::fs::symlink_metadata(path)
378 .map(|m| m.file_type().is_socket())
379 .unwrap_or(false)
380 }
381 #[cfg(not(unix))]
382 {
383 path.exists()
384 }
385}
386
387pub fn create_memory_backend(
403 settings: &crate::store::settings::Settings,
404) -> Option<Arc<dyn oxicode_agent::tools::MemoryBackend>> {
405 if !settings.memory_enabled {
406 return None;
407 }
408 let socket = crate::foundation::brain::default_socket_path();
409 if brain_socket_present(&socket) {
410 let backend = crate::foundation::brain::BrainMemoryBackend::new(socket.clone());
411 tracing::info!(
412 "durable memory authority is oxibrain at {}",
413 socket.display()
414 );
415 return Some(Arc::new(backend));
416 }
417 tracing::warn!(
418 "memory_enabled but no oxibrain socket at {} — durable-memory tools \
419 will return typed unavailable results. Start the daemon with \
420 `oxibrain serve` or set OXIBRAIN_SOCKET.",
421 socket.display()
422 );
423 None
424}
425
426pub(crate) fn initial_brain_chip(
428 settings: &crate::store::settings::Settings,
429) -> crate::tui_vt::main_loop::BrainChip {
430 use crate::tui_vt::main_loop::BrainChip;
431 if !settings.memory_enabled {
432 return BrainChip::Off;
433 }
434 if brain_socket_present(&crate::foundation::brain::default_socket_path()) {
435 BrainChip::Degraded
437 } else {
438 BrainChip::Down
439 }
440}
441#[cfg(test)]
442mod memory_backend_tests {
443 use super::*;
444
445 #[cfg(unix)]
446 fn test_settings() -> crate::store::settings::Settings {
447 let mut s = crate::store::settings::Settings::default();
448 s.memory_enabled = true;
449 s
450 }
451
452 #[cfg(unix)]
453 #[test]
454 fn brain_backend_returned_when_socket_present() {
455 let dir = tempfile::TempDir::new().unwrap();
458 let sock = dir.path().join("oxibrain.sock");
459 let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
460 unsafe {
461 std::env::set_var("OXIBRAIN_SOCKET", &sock);
462 }
463 let backend = create_memory_backend(&test_settings());
464 assert!(backend.is_some(), "socket present ⇒ brain backend");
465 unsafe {
466 std::env::remove_var("OXIBRAIN_SOCKET");
467 }
468 }
469
470 #[cfg(unix)]
471 #[test]
472 fn absent_socket_returns_none() {
473 let dir = tempfile::TempDir::new().unwrap();
474 unsafe {
475 std::env::set_var("OXIBRAIN_SOCKET", dir.path().join("missing.sock"));
476 }
477 let backend = create_memory_backend(&test_settings());
478 assert!(
479 backend.is_none(),
480 "absent socket ⇒ no local durable fallback (plan §5.h)"
481 );
482 unsafe {
483 std::env::remove_var("OXIBRAIN_SOCKET");
484 }
485 }
486
487 #[cfg(unix)]
488 #[test]
489 fn regular_file_is_not_a_socket() {
490 let dir = tempfile::TempDir::new().unwrap();
491 let fake = dir.path().join("oxibrain.sock");
492 std::fs::write(&fake, b"not a socket").unwrap();
493 unsafe {
494 std::env::set_var("OXIBRAIN_SOCKET", &fake);
495 }
496 let backend = create_memory_backend(&test_settings());
497 assert!(
498 backend.is_none(),
499 "regular file must not pass the socket gate"
500 );
501 unsafe {
502 std::env::remove_var("OXIBRAIN_SOCKET");
503 }
504 }
505
506 #[test]
507 fn memory_disabled_returns_none() {
508 let mut s = crate::store::settings::Settings::default();
511 assert!(s.memory_enabled, "memory_enabled default flipped to true");
512 s.memory_enabled = false;
513 assert!(create_memory_backend(&s).is_none());
514 }
515}
516
517pub async fn build_memory_recall(
520 backend: &dyn oxicode_agent::tools::MemoryBackend,
521 subject: &str,
522) -> String {
523 match backend.list(subject).await {
524 Ok(items) if !items.is_empty() => {
525 let mut block = String::from(
526 "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
527 );
528 for item in &items {
529 block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
530 }
531 block
532 }
533 _ => String::new(),
534 }
535}
536
537pub async fn session_reflect(
543 backend: &dyn oxicode_agent::tools::MemoryBackend,
544 subject: &str,
545 summary: &str,
546) {
547 if let Err(e) = backend.put(summary, "summary", subject).await {
548 tracing::warn!("Failed to store session memory: {e}");
549 }
550}
551
552async fn resolve_and_register_profile(
567 foundation_root: &Path,
568) -> Result<Arc<dyn oxicode_ai::Provider>, crate::foundation::FoundationError> {
569 use crate::foundation::profiles::{
570 EnvironmentOverride, ResolveInput, read as read_profiles, resolve_profile,
571 };
572
573 let profiles_path = foundation_root.join(crate::foundation::files::PROFILES);
574 let profiles = read_profiles(&profiles_path)?;
575 let explicit_profile = std::env::var("OXICODE_PROFILE")
576 .ok()
577 .filter(|s| !s.trim().is_empty());
578 let compat_import_path = foundation_root.join("compatibility.json");
579 let compat_import =
580 crate::foundation::compat_import::read_compatibility_shim(&compat_import_path)?;
581
582 let env_override = EnvironmentOverride::from_env();
583
584 let resolved = resolve_profile(ResolveInput {
585 explicit_profile: explicit_profile.as_deref(),
586 explicit_environment_override: env_override.as_ref(),
587 requested_role: None,
588 foundation_profiles: &profiles,
589 compatibility_import: compat_import.as_ref(),
590 })?;
591
592 let resolver = crate::foundation::credentials::KeychainCredentialResolver::default();
593 let credential = resolver.resolve(&resolved.profile);
594 let api_key = match credential {
595 crate::foundation::credentials::Credential::Keychain(s)
596 | crate::foundation::credentials::Credential::Environment(s) => s,
597 crate::foundation::credentials::Credential::Unavailable(e) => {
598 return Err(crate::foundation::FoundationError::KeychainUnavailable(
599 e.to_string(),
600 ));
601 }
602 };
603 let provider_name = resolved.profile.provider.as_str();
604 let provider: Arc<dyn oxicode_ai::Provider> = Arc::from(
605 oxicode_ai::register_builtins::create_builtin_provider_with_options(
606 provider_name,
607 Some(&api_key),
608 None,
609 )
610 .ok_or_else(|| {
611 crate::foundation::FoundationError::IncompatibleHost(provider_name.to_string())
612 })?,
613 );
614
615 tracing::info!(
616 provider = provider_name,
617 model = %resolved.profile.model,
618 source = ?resolved.source,
619 "Foundation profile resolved with Keychain credential"
620 );
621 Ok(provider)
622}
623#[cfg(test)]
624mod tests {
625
626 use super::*;
627
628 #[test]
629 fn paths_are_consistent() {
630 let p = OxicodePaths::from_home("/tmp/oxicode-test");
631 assert!(p.auth.starts_with("/tmp/oxicode-test"));
632 assert!(p.config.starts_with("/tmp/oxicode-test"));
633 assert!(p.sessions.starts_with("/tmp/oxicode-test"));
634 assert!(p.skills.starts_with("/tmp/oxicode-test"));
635 }
636
637 #[tokio::test]
638 async fn build_oxicode_succeeds() {
639 let tmp = tempfile::TempDir::new().unwrap();
640 let paths = OxicodePaths::from_home(tmp.path());
641 let oxicode = build_oxicode(&paths, None, None).await.unwrap();
642 let _ = oxicode.ports().state;
643 }
644}