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 static ENV_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
449
450 fn test_settings() -> crate::store::settings::Settings {
451 let mut s = crate::store::settings::Settings::default();
452 s.memory_enabled = true;
453 s
454 }
455
456 #[cfg(unix)]
459 fn with_socket_env<T>(value: Option<&std::path::Path>, f: impl FnOnce() -> T) -> T {
460 let _guard = ENV_LOCK.lock();
461 unsafe {
462 match value {
463 Some(p) => std::env::set_var("OXIBRAIN_SOCKET", p),
464 None => std::env::remove_var("OXIBRAIN_SOCKET"),
465 }
466 }
467 let out = f();
468 unsafe {
469 std::env::remove_var("OXIBRAIN_SOCKET");
470 }
471 out
472 }
473
474 #[cfg(unix)]
477 fn bind_real_socket() -> std::path::PathBuf {
478 let dir = tempfile::TempDir::new().unwrap();
479 let sock = dir.path().join("oxibrain.sock");
480 std::os::unix::net::UnixListener::bind(&sock).unwrap();
481 std::mem::forget(dir);
482 sock
483 }
484
485 #[cfg(unix)]
486 #[test]
487 fn brain_backend_returned_when_socket_present() {
488 let sock = bind_real_socket();
490 with_socket_env(Some(&sock), || {
491 let backend = create_memory_backend(&test_settings());
492 assert!(backend.is_some(), "socket present ⇒ brain backend");
493 });
494 }
495
496 #[cfg(unix)]
497 #[test]
498 fn absent_socket_returns_none() {
499 let dir = tempfile::TempDir::new().unwrap();
500 let missing = dir.path().join("missing.sock");
501 with_socket_env(Some(&missing), || {
502 let backend = create_memory_backend(&test_settings());
503 assert!(
504 backend.is_none(),
505 "absent socket ⇒ no local durable fallback (plan §5.h)"
506 );
507 });
508 }
509
510 #[cfg(unix)]
511 #[test]
512 fn regular_file_is_not_a_socket() {
513 let dir = tempfile::TempDir::new().unwrap();
514 let fake = dir.path().join("oxibrain.sock");
515 std::fs::write(&fake, b"not a socket").unwrap();
516 with_socket_env(Some(&fake), || {
517 let backend = create_memory_backend(&test_settings());
518 assert!(
519 backend.is_none(),
520 "regular file must not pass the socket gate"
521 );
522 });
523 }
524
525 #[test]
526 fn memory_disabled_returns_none() {
527 let mut s = crate::store::settings::Settings::default();
530 assert!(s.memory_enabled, "memory_enabled default flipped to true");
531 s.memory_enabled = false;
532 assert!(create_memory_backend(&s).is_none());
533 }
534}
535
536pub async fn build_memory_recall(
539 backend: &dyn oxicode_agent::tools::MemoryBackend,
540 subject: &str,
541) -> String {
542 match backend.list(subject).await {
543 Ok(items) if !items.is_empty() => {
544 let mut block = String::from(
545 "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
546 );
547 for item in &items {
548 block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
549 }
550 block
551 }
552 _ => String::new(),
553 }
554}
555
556pub async fn session_reflect(
562 backend: &dyn oxicode_agent::tools::MemoryBackend,
563 subject: &str,
564 summary: &str,
565) {
566 if let Err(e) = backend.put(summary, "summary", subject).await {
567 tracing::warn!("Failed to store session memory: {e}");
568 }
569}
570
571async fn resolve_and_register_profile(
586 foundation_root: &Path,
587) -> Result<Arc<dyn oxicode_ai::Provider>, crate::foundation::FoundationError> {
588 use crate::foundation::profiles::{
589 EnvironmentOverride, ResolveInput, read as read_profiles, resolve_profile,
590 };
591
592 let profiles_path = foundation_root.join(crate::foundation::files::PROFILES);
593 let profiles = read_profiles(&profiles_path)?;
594 let explicit_profile = std::env::var("OXICODE_PROFILE")
595 .ok()
596 .filter(|s| !s.trim().is_empty());
597 let compat_import_path = foundation_root.join("compatibility.json");
598 let compat_import =
599 crate::foundation::compat_import::read_compatibility_shim(&compat_import_path)?;
600
601 let env_override = EnvironmentOverride::from_env();
602
603 let resolved = resolve_profile(ResolveInput {
604 explicit_profile: explicit_profile.as_deref(),
605 explicit_environment_override: env_override.as_ref(),
606 requested_role: None,
607 foundation_profiles: &profiles,
608 compatibility_import: compat_import.as_ref(),
609 })?;
610
611 let resolver = crate::foundation::credentials::KeychainCredentialResolver::default();
612 let credential = resolver.resolve(&resolved.profile);
613 let api_key = match credential {
614 crate::foundation::credentials::Credential::Keychain(s)
615 | crate::foundation::credentials::Credential::Environment(s) => s,
616 crate::foundation::credentials::Credential::Unavailable(e) => {
617 return Err(crate::foundation::FoundationError::KeychainUnavailable(
618 e.to_string(),
619 ));
620 }
621 };
622 let provider_name = resolved.profile.provider.as_str();
623 let provider: Arc<dyn oxicode_ai::Provider> = Arc::from(
624 oxicode_ai::register_builtins::create_builtin_provider_with_options(
625 provider_name,
626 Some(&api_key),
627 None,
628 )
629 .ok_or_else(|| {
630 crate::foundation::FoundationError::IncompatibleHost(provider_name.to_string())
631 })?,
632 );
633
634 tracing::info!(
635 provider = provider_name,
636 model = %resolved.profile.model,
637 source = ?resolved.source,
638 "Foundation profile resolved with Keychain credential"
639 );
640 Ok(provider)
641}
642#[cfg(test)]
643mod tests {
644
645 use super::*;
646
647 #[test]
648 fn paths_are_consistent() {
649 let p = OxicodePaths::from_home("/tmp/oxicode-test");
650 assert!(p.auth.starts_with("/tmp/oxicode-test"));
651 assert!(p.config.starts_with("/tmp/oxicode-test"));
652 assert!(p.sessions.starts_with("/tmp/oxicode-test"));
653 assert!(p.skills.starts_with("/tmp/oxicode-test"));
654 }
655
656 #[tokio::test]
657 async fn build_oxicode_succeeds() {
658 let tmp = tempfile::TempDir::new().unwrap();
659 let paths = OxicodePaths::from_home(tmp.path());
660 let oxicode = build_oxicode(&paths, None, None).await.unwrap();
661 let _ = oxicode.ports().state;
662 }
663}