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;
34use crate::store::extracting_backend;
35use crate::store::memory_summary;
36use crate::store::memory_workers;
37
38#[derive(Debug, Clone)]
40pub struct OxicodePaths {
41 pub home: PathBuf,
43 pub auth: PathBuf,
45 pub config: PathBuf,
47 pub sessions: PathBuf,
49 pub skills: PathBuf,
51}
52
53impl OxicodePaths {
54 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 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
74pub 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
97pub 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
195fn 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
214fn 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
229pub 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
278pub 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
294fn 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
321pub struct MnemopiEmbeddingBridge {
327 inner: Arc<dyn oxicode_mnemopi::EmbeddingProvider>,
328}
329
330impl MnemopiEmbeddingBridge {
331 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
360pub 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 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
415pub 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
424pub 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
444pub 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
455pub 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
470pub 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 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}