1#![allow(clippy::cast_precision_loss)]
7#![allow(clippy::option_if_let_else)]
9#![allow(clippy::significant_drop_tightening)]
11#![allow(clippy::unused_self)]
13#![allow(clippy::trivially_copy_pass_by_ref)]
15#![allow(clippy::unnecessary_wraps)]
17#![allow(clippy::manual_let_else)]
19#![allow(clippy::or_fun_call)]
21
22mod capture;
23mod consolidation;
24mod context;
25mod enrichment;
26mod query_parser;
27mod recall;
28mod sync;
29mod topic_index;
30
31pub use capture::CaptureService;
32pub use consolidation::ConsolidationService;
33pub use context::{ContextBuilderService, MemoryStatistics};
34pub use enrichment::{EnrichmentResult, EnrichmentService, EnrichmentStats};
35pub use query_parser::parse_filter_query;
36pub use recall::RecallService;
37pub use sync::SyncService;
38pub use topic_index::{TopicIndexService, TopicInfo};
39
40use crate::git::{NotesManager, YamlFrontMatterParser};
41use crate::models::{Domain, Memory, MemoryId, MemoryStatus, Namespace};
42use crate::storage::index::{
43 DomainIndexConfig, DomainIndexManager, DomainScope, OrgIndexConfig, SqliteBackend,
44 find_repo_root,
45};
46use crate::storage::traits::IndexBackend;
47use crate::{Error, Result};
48use std::path::PathBuf;
49use std::sync::Mutex;
50
51pub struct ServiceContainer {
56 capture: CaptureService,
58 sync: SyncService,
60 index_manager: Mutex<DomainIndexManager>,
62 repo_path: Option<PathBuf>,
64}
65
66impl ServiceContainer {
67 pub fn for_repo(
78 repo_path: impl Into<PathBuf>,
79 org_config: Option<OrgIndexConfig>,
80 ) -> Result<Self> {
81 let repo_path = repo_path.into();
82
83 let repo_root = find_repo_root(&repo_path)?;
85
86 let config = DomainIndexConfig {
87 repo_path: Some(repo_root.clone()),
88 org_config,
89 };
90
91 let index_manager = DomainIndexManager::new(config)?;
92
93 let capture_config = crate::config::Config::new().with_repo_path(&repo_root);
95
96 Ok(Self {
97 capture: CaptureService::new(capture_config),
98 sync: SyncService::default(),
99 index_manager: Mutex::new(index_manager),
100 repo_path: Some(repo_root),
101 })
102 }
103
104 pub fn from_current_dir() -> Result<Self> {
110 let cwd = std::env::current_dir().map_err(|e| Error::OperationFailed {
111 operation: "get_current_dir".to_string(),
112 cause: e.to_string(),
113 })?;
114
115 Self::for_repo(cwd, None)
116 }
117
118 pub fn recall_for_scope(&self, scope: DomainScope) -> Result<RecallService> {
124 let manager = self
125 .index_manager
126 .lock()
127 .map_err(|e| Error::OperationFailed {
128 operation: "lock_index_manager".to_string(),
129 cause: e.to_string(),
130 })?;
131
132 let index_path = manager.get_index_path(scope)?;
133
134 if let Some(parent) = index_path.parent() {
136 std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
137 operation: "create_index_dir".to_string(),
138 cause: e.to_string(),
139 })?;
140 }
141
142 let index = SqliteBackend::new(&index_path)?;
143 Ok(RecallService::with_index(index))
144 }
145
146 pub fn recall(&self) -> Result<RecallService> {
152 self.recall_for_scope(DomainScope::Project)
153 }
154
155 #[must_use]
157 pub const fn capture(&self) -> &CaptureService {
158 &self.capture
159 }
160
161 #[must_use]
163 pub const fn sync(&self) -> &SyncService {
164 &self.sync
165 }
166
167 #[must_use]
169 pub const fn repo_path(&self) -> Option<&PathBuf> {
170 self.repo_path.as_ref()
171 }
172
173 pub fn get_index_path(&self, scope: DomainScope) -> Result<PathBuf> {
179 let manager = self
180 .index_manager
181 .lock()
182 .map_err(|e| Error::OperationFailed {
183 operation: "lock_index_manager".to_string(),
184 cause: e.to_string(),
185 })?;
186 manager.get_index_path(scope)
187 }
188
189 pub fn reindex_scope(&self, scope: DomainScope) -> Result<usize> {
203 let repo_path = self
204 .repo_path
205 .as_ref()
206 .ok_or_else(|| Error::InvalidInput("Repository path not configured".to_string()))?;
207
208 let notes = NotesManager::new(repo_path);
209
210 let all_notes = notes.list()?;
212
213 if all_notes.is_empty() {
214 return Ok(0);
215 }
216
217 let mut memories = Vec::with_capacity(all_notes.len());
219 for (note_id, content) in &all_notes {
220 match parse_note_to_memory(note_id, content) {
221 Ok(memory) => memories.push(memory),
222 Err(e) => {
223 tracing::warn!("Failed to parse note {note_id}: {e}");
224 },
225 }
226 }
227
228 if memories.is_empty() {
229 return Ok(0);
230 }
231
232 let index_path = self.get_index_path(scope)?;
234
235 if let Some(parent) = index_path.parent() {
237 std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
238 operation: "create_index_dir".to_string(),
239 cause: e.to_string(),
240 })?;
241 }
242
243 let mut index = SqliteBackend::new(&index_path)?;
244
245 index.clear()?;
247 let count = memories.len();
248 index.reindex(&memories)?;
249
250 Ok(count)
251 }
252
253 pub fn reindex(&self) -> Result<usize> {
259 self.reindex_scope(DomainScope::Project)
260 }
261
262 pub fn reindex_all(&self) -> Result<std::collections::HashMap<DomainScope, usize>> {
272 let mut results = std::collections::HashMap::new();
273
274 for scope in [DomainScope::Project, DomainScope::User, DomainScope::Org] {
275 match self.reindex_scope(scope) {
276 Ok(count) => {
277 results.insert(scope, count);
278 },
279 Err(e) => {
280 tracing::warn!("Failed to reindex scope {:?}: {e}", scope);
281 },
282 }
283 }
284
285 Ok(results)
286 }
287}
288
289use once_cell::sync::OnceCell;
291static LEGACY_SERVICES: OnceCell<LegacyServiceContainer> = OnceCell::new();
292
293pub struct LegacyServiceContainer {
297 recall: RecallService,
298 capture: CaptureService,
299 sync: SyncService,
300 index: Mutex<SqliteBackend>,
301 data_dir: PathBuf,
302}
303
304impl LegacyServiceContainer {
305 #[deprecated(note = "Use ServiceContainer::for_repo() instead")]
311 pub fn init(data_dir: Option<PathBuf>) -> Result<&'static Self> {
312 LEGACY_SERVICES.get_or_try_init(|| {
313 let data_dir = data_dir.unwrap_or_else(|| {
314 directories::BaseDirs::new()
315 .map_or_else(|| PathBuf::from("."), |b| b.data_local_dir().to_path_buf())
316 .join("subcog")
317 });
318
319 std::fs::create_dir_all(&data_dir).map_err(|e| Error::OperationFailed {
320 operation: "create_data_dir".to_string(),
321 cause: e.to_string(),
322 })?;
323
324 let db_path = data_dir.join("index.db");
325 let index = SqliteBackend::new(&db_path)?;
326 let recall_index = SqliteBackend::new(&db_path)?;
327
328 Ok(Self {
329 recall: RecallService::with_index(recall_index),
330 capture: CaptureService::default(),
331 sync: SyncService::default(),
332 index: Mutex::new(index),
333 data_dir,
334 })
335 })
336 }
337
338 #[deprecated(note = "Use ServiceContainer::for_repo() instead")]
344 #[allow(deprecated)]
345 pub fn get() -> Result<&'static Self> {
346 Self::init(None)
347 }
348
349 #[must_use]
351 pub const fn recall(&self) -> &RecallService {
352 &self.recall
353 }
354
355 #[must_use]
357 pub const fn capture(&self) -> &CaptureService {
358 &self.capture
359 }
360
361 #[must_use]
363 pub const fn sync(&self) -> &SyncService {
364 &self.sync
365 }
366
367 #[must_use]
369 pub const fn data_dir(&self) -> &PathBuf {
370 &self.data_dir
371 }
372
373 pub fn reindex(&self, repo_path: &std::path::Path) -> Result<usize> {
379 let notes = NotesManager::new(repo_path);
380 let all_notes = notes.list()?;
381
382 if all_notes.is_empty() {
383 return Ok(0);
384 }
385
386 let mut memories = Vec::with_capacity(all_notes.len());
387 for (note_id, content) in &all_notes {
388 match parse_note_to_memory(note_id, content) {
389 Ok(memory) => memories.push(memory),
390 Err(e) => {
391 tracing::warn!("Failed to parse note {note_id}: {e}");
392 },
393 }
394 }
395
396 if memories.is_empty() {
397 return Ok(0);
398 }
399
400 let mut index = self.index.lock().map_err(|e| Error::OperationFailed {
401 operation: "lock_index".to_string(),
402 cause: e.to_string(),
403 })?;
404
405 index.clear()?;
406 let count = memories.len();
407 index.reindex(&memories)?;
408
409 Ok(count)
410 }
411}
412
413fn parse_note_to_memory(note_id: &str, content: &str) -> Result<Memory> {
424 let (metadata, body) = YamlFrontMatterParser::parse(content)?;
425
426 let id = metadata
428 .get("id")
429 .and_then(|v| v.as_str())
430 .map_or_else(|| MemoryId::new(note_id), MemoryId::new);
431
432 let namespace = metadata
433 .get("namespace")
434 .and_then(|v| v.as_str())
435 .and_then(Namespace::parse)
436 .unwrap_or_default();
437
438 let domain = metadata
439 .get("domain")
440 .and_then(|v| v.as_str())
441 .map_or_else(Domain::new, parse_domain_string);
442
443 let status = metadata
444 .get("status")
445 .and_then(|v| v.as_str())
446 .map_or(MemoryStatus::Active, parse_status_string);
447
448 let created_at = metadata
449 .get("created_at")
450 .and_then(serde_json::Value::as_u64)
451 .unwrap_or(0);
452
453 let updated_at = metadata
454 .get("updated_at")
455 .and_then(serde_json::Value::as_u64)
456 .unwrap_or(created_at);
457
458 let tags = metadata
459 .get("tags")
460 .and_then(|v| v.as_array())
461 .map_or_else(Vec::new, |arr| {
462 arr.iter()
463 .filter_map(|v| v.as_str().map(String::from))
464 .collect()
465 });
466
467 let source = metadata
468 .get("source")
469 .and_then(|v| v.as_str())
470 .map(String::from);
471
472 Ok(Memory {
473 id,
474 content: body,
475 namespace,
476 domain,
477 status,
478 created_at,
479 updated_at,
480 embedding: None,
481 tags,
482 source,
483 })
484}
485
486fn parse_domain_string(s: &str) -> Domain {
488 if s == "global" || s.is_empty() {
489 return Domain::new();
490 }
491
492 let parts: Vec<&str> = s.split('/').collect();
493 match parts.len() {
494 1 => Domain {
495 organization: Some(parts[0].to_string()),
496 project: None,
497 repository: None,
498 },
499 2 => Domain {
500 organization: Some(parts[0].to_string()),
501 project: None,
502 repository: Some(parts[1].to_string()),
503 },
504 3 => Domain {
505 organization: Some(parts[0].to_string()),
506 project: Some(parts[1].to_string()),
507 repository: Some(parts[2].to_string()),
508 },
509 _ => Domain::new(),
510 }
511}
512
513fn parse_status_string(s: &str) -> MemoryStatus {
515 match s.to_lowercase().as_str() {
516 "archived" => MemoryStatus::Archived,
517 "superseded" => MemoryStatus::Superseded,
518 "pending" => MemoryStatus::Pending,
519 "deleted" => MemoryStatus::Deleted,
520 _ => MemoryStatus::Active,
522 }
523}