1use std::collections::{BTreeMap, HashSet};
4use std::ffi::OsString;
5use std::io;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10pub use jiandu_memory::memory_store::{
11 count_chars, normalize_retrieval_terms, normalize_tags, render_memory_freshness_note,
12 summary_json, truncate_chars, BlobScanItem, BlobScanReport, DreamReadResult, DreamSnapshot,
13 DuplicateCluster, DuplicateScanReport, DurableMemoryDocument, DurableMemoryStatus,
14 DurableMemoryType, FreshnessKind, MemoryConsolidateResult, MemoryContradictionResult,
15 MemoryDuplicateCandidate, MemoryMergeResult, MemoryPurgeResult, MemoryQueryOptions,
16 MemoryQueryResult, MemoryRecallCandidate, MemoryRecallOptions, MemoryRetrievalInput,
17 MemoryScope, MemorySplitPiece, MemorySplitResult, ProjectMemoryCandidates, ProjectMemoryRead,
18 ProjectMemoryRef, SessionState, TemporalGranularity, DEFAULT_QUERY_LIMIT,
19 DEFAULT_SESSION_TOPIC, MAX_EXPLICIT_MEMORY_ENTITIES, MAX_EXPLICIT_MEMORY_KEYWORDS,
20 MAX_MAX_CHARS, MAX_MEMORY_ENTITIES, MAX_MEMORY_ID_LEN, MAX_MEMORY_KEYWORDS,
21 MAX_MEMORY_QUERY_CHARS, MAX_MEMORY_TAGS, MAX_MEMORY_TAG_CHARS, MAX_MEMORY_TITLE_LEN,
22 MAX_QUERY_LIMIT, MAX_RETRIEVAL_TERM_CHARS,
23};
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct MemoryInspectResult {
28 pub scope: MemoryScope,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub project_key: Option<String>,
31 pub total_memories: usize,
32 #[serde(default)]
33 pub by_type: BTreeMap<String, usize>,
34 #[serde(default)]
35 pub by_status: BTreeMap<String, usize>,
36 #[serde(default)]
37 pub recent_ids: Vec<String>,
38 #[serde(default)]
39 pub view_files: Vec<String>,
40 #[serde(default)]
41 pub index_files: Vec<String>,
42 #[serde(default)]
43 pub state_files: Vec<String>,
44 #[serde(default)]
45 pub stale_candidate_count: usize,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub last_reindex_at: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub last_dream_at: Option<String>,
50 #[serde(default)]
51 pub topic_paths: Vec<String>,
52}
53
54pub const BAMBOO_JIANDU_DATA_DIR_ENV: &str = "BAMBOO_JIANDU_DATA_DIR";
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum JianduDataRoot {
64 CanonicalDefault,
65 Explicit(PathBuf),
66}
67
68impl JianduDataRoot {
69 pub fn mode(&self) -> &'static str {
70 match self {
71 Self::CanonicalDefault => "default",
72 Self::Explicit(_) => "explicit",
73 }
74 }
75
76 pub fn into_path(self) -> PathBuf {
77 match self {
78 Self::CanonicalDefault => MemoryStore::default_data_dir(),
79 Self::Explicit(root) => root,
80 }
81 }
82}
83
84pub fn resolve_jiandu_data_root(explicit: Option<OsString>) -> Result<JianduDataRoot, String> {
88 let Some(explicit) = explicit else {
89 return Ok(JianduDataRoot::CanonicalDefault);
90 };
91 if explicit.is_empty() {
92 return Err(format!(
93 "{BAMBOO_JIANDU_DATA_DIR_ENV} must be a non-empty absolute path when set"
94 ));
95 }
96
97 let root = PathBuf::from(explicit);
98 if !root.is_absolute() {
99 return Err(format!(
100 "{BAMBOO_JIANDU_DATA_DIR_ENV} must be an absolute path when set"
101 ));
102 }
103 Ok(JianduDataRoot::Explicit(root))
104}
105
106#[derive(Debug, Clone)]
109pub struct MemoryStore {
110 store: jiandu_memory::memory_store::MemoryStore,
111}
112
113fn with_unknown_session_ranges(mut document: DurableMemoryDocument) -> DurableMemoryDocument {
118 for source in &mut document.frontmatter.sources {
119 if source.kind == "session" {
120 source.message_range.clear();
121 }
122 }
123 document
124}
125
126impl Default for MemoryStore {
127 fn default() -> Self {
128 Self::with_defaults()
129 }
130}
131
132impl MemoryStore {
133 pub async fn query_project_sources_read_only(
135 &self,
136 project: &bamboo_domain::ProjectId,
137 query: &str,
138 ) -> io::Result<ProjectMemoryCandidates> {
139 let project: jiandu_memory::ProjectId = project
140 .as_str()
141 .parse::<jiandu_memory::ProjectId>()
142 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
143 self.store
144 .for_project(&project)
145 .query_project_sources_read_only(&project, query)
146 .await
147 }
148
149 pub async fn read_project_source_read_only(
151 &self,
152 project: &bamboo_domain::ProjectId,
153 memory_id: &str,
154 content_version: &str,
155 ) -> io::Result<ProjectMemoryRead> {
156 let project: jiandu_memory::ProjectId = project
157 .as_str()
158 .parse::<jiandu_memory::ProjectId>()
159 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
160 let reference = ProjectMemoryRef::new(project, memory_id, content_version)?;
161 self.store
162 .for_project(reference.project_id())
163 .get_project_source_read_only(&reference)
164 .await
165 }
166
167 pub fn data_dir(&self) -> PathBuf {
170 self.store.resolver().data_dir()
171 }
172
173 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
175 Self {
176 store: jiandu_memory::memory_store::MemoryStore::new(data_dir),
177 }
178 }
179
180 pub fn with_defaults() -> Self {
182 Self::new(Self::default_data_dir())
183 }
184
185 pub fn default_data_dir() -> PathBuf {
187 dirs::home_dir().map_or_else(|| PathBuf::from(".jiandu"), |home| home.join(".jiandu"))
188 }
189
190 pub fn for_project(&self, project_id: &bamboo_domain::ProjectId) -> Self {
192 let project_id = jiandu_memory::ProjectId::parse(project_id.as_str().to_owned())
193 .expect("Bamboo ProjectId must satisfy Jiandu's identical path-safe contract");
194 Self {
195 store: self.store.for_project(&project_id),
196 }
197 }
198
199 pub async fn read_session_topic(
200 &self,
201 session_id: &str,
202 topic: &str,
203 ) -> io::Result<Option<String>> {
204 self.store.read_session_topic(session_id, topic).await
205 }
206
207 pub async fn write_session_topic(
208 &self,
209 session_id: &str,
210 topic: &str,
211 content: &str,
212 ) -> io::Result<PathBuf> {
213 self.store
214 .write_session_topic(session_id, topic, content)
215 .await
216 }
217
218 pub async fn delete_session_topic(&self, session_id: &str, topic: &str) -> io::Result<bool> {
219 self.store.delete_session_topic(session_id, topic).await
220 }
221
222 pub async fn list_session_topics(&self, session_id: &str) -> io::Result<Vec<String>> {
223 self.store.list_session_topics(session_id).await
224 }
225
226 pub async fn read_session_topics_with_content(
227 &self,
228 session_id: &str,
229 ) -> io::Result<Vec<(String, String)>> {
230 self.store
231 .read_session_topics_with_content(session_id)
232 .await
233 }
234
235 pub async fn read_session_state(&self, session_id: &str) -> io::Result<SessionState> {
236 self.store.read_session_state(session_id).await
237 }
238
239 pub async fn mark_session_extracted(
240 &self,
241 session_id: &str,
242 extracted_at: &str,
243 ) -> io::Result<()> {
244 self.store
245 .mark_session_extracted(session_id, extracted_at)
246 .await
247 }
248
249 pub async fn read_memory_view(
250 &self,
251 scope: MemoryScope,
252 project_key: Option<&str>,
253 ) -> io::Result<Option<String>> {
254 self.store.read_memory_view(scope, project_key).await
255 }
256
257 #[allow(clippy::too_many_arguments)]
258 pub async fn query_scope(
259 &self,
260 scope: MemoryScope,
261 project_key: Option<&str>,
262 query: Option<&str>,
263 filter_types: Option<&HashSet<DurableMemoryType>>,
264 filter_statuses: Option<&HashSet<DurableMemoryStatus>>,
265 filter_granularity: Option<&HashSet<TemporalGranularity>>,
266 options: &MemoryQueryOptions,
267 ) -> io::Result<MemoryQueryResult> {
268 self.store
269 .query_scope(
270 scope,
271 project_key,
272 query,
273 filter_types,
274 filter_statuses,
275 filter_granularity,
276 options,
277 )
278 .await
279 }
280
281 pub async fn inspect_scope(
282 &self,
283 scope: MemoryScope,
284 project_key: Option<&str>,
285 ) -> io::Result<MemoryInspectResult> {
286 let result = self.store.inspect_scope(scope, project_key).await?;
287 let last_dream_at = if scope == MemoryScope::Session {
288 None
289 } else {
290 self.store
291 .read_dream_snapshot(scope, project_key)
292 .await?
293 .snapshot
294 .map(|snapshot| snapshot.generated_at)
295 };
296
297 Ok(MemoryInspectResult {
298 scope: result.scope,
299 project_key: result.project_key,
300 total_memories: result.total_memories,
301 by_type: result.by_type,
302 by_status: result.by_status,
303 recent_ids: result.recent_ids,
304 view_files: result.view_files,
305 index_files: result.index_files,
306 state_files: result.state_files,
307 stale_candidate_count: result.stale_candidate_count,
308 last_reindex_at: result.last_reindex_at,
309 last_dream_at,
310 topic_paths: result.topic_paths,
311 })
312 }
313
314 pub async fn get_memory(
315 &self,
316 id: &str,
317 preferred_project_key: Option<&str>,
318 ) -> io::Result<Option<DurableMemoryDocument>> {
319 self.store
320 .get_memory(id, preferred_project_key)
321 .await
322 .map(|document| document.map(with_unknown_session_ranges))
323 }
324
325 #[allow(clippy::too_many_arguments)]
326 pub async fn write_memory(
327 &self,
328 scope: MemoryScope,
329 project_key: Option<&str>,
330 r#type: DurableMemoryType,
331 title: &str,
332 content: &str,
333 tags: &[String],
334 session_id: Option<&str>,
335 actor: &str,
336 allow_merge_if_similar: bool,
337 granularity: Option<TemporalGranularity>,
338 ) -> io::Result<DurableMemoryDocument> {
339 self.store
340 .write_memory(
341 scope,
342 project_key,
343 r#type,
344 title,
345 content,
346 tags,
347 session_id,
348 actor,
349 allow_merge_if_similar,
350 granularity,
351 )
352 .await
353 .map(with_unknown_session_ranges)
354 }
355
356 #[allow(clippy::too_many_arguments)]
357 pub async fn write_memory_with_retrieval(
358 &self,
359 scope: MemoryScope,
360 project_key: Option<&str>,
361 r#type: DurableMemoryType,
362 title: &str,
363 content: &str,
364 tags: &[String],
365 retrieval: &MemoryRetrievalInput,
366 session_id: Option<&str>,
367 actor: &str,
368 allow_merge_if_similar: bool,
369 granularity: Option<TemporalGranularity>,
370 ) -> io::Result<DurableMemoryDocument> {
371 self.store
372 .write_memory_with_retrieval(
373 scope,
374 project_key,
375 r#type,
376 title,
377 content,
378 tags,
379 retrieval,
380 session_id,
381 actor,
382 allow_merge_if_similar,
383 granularity,
384 )
385 .await
386 .map(with_unknown_session_ranges)
387 }
388
389 pub async fn archive_memory(
390 &self,
391 id: &str,
392 preferred_project_key: Option<&str>,
393 mode: DurableMemoryStatus,
394 reason: Option<&str>,
395 ) -> io::Result<Option<DurableMemoryDocument>> {
396 self.store
397 .archive_memory(id, preferred_project_key, mode, reason)
398 .await
399 .map(|document| document.map(with_unknown_session_ranges))
400 }
401
402 pub async fn split_memory(
403 &self,
404 id: &str,
405 preferred_project_key: Option<&str>,
406 pieces: &[MemorySplitPiece],
407 session_id: Option<&str>,
408 actor: &str,
409 ) -> io::Result<Option<MemorySplitResult>> {
410 self.store
411 .split_memory(id, preferred_project_key, pieces, session_id, actor)
412 .await
413 }
414
415 pub async fn split_memory_with_retrieval(
416 &self,
417 id: &str,
418 preferred_project_key: Option<&str>,
419 pieces: &[MemorySplitPiece],
420 retrieval: &[MemoryRetrievalInput],
421 session_id: Option<&str>,
422 actor: &str,
423 ) -> io::Result<Option<MemorySplitResult>> {
424 self.store
425 .split_memory_with_retrieval(
426 id,
427 preferred_project_key,
428 pieces,
429 retrieval,
430 session_id,
431 actor,
432 )
433 .await
434 }
435
436 #[allow(clippy::too_many_arguments)]
437 pub async fn find_duplicate_candidates(
438 &self,
439 scope: MemoryScope,
440 project_key: Option<&str>,
441 r#type: Option<DurableMemoryType>,
442 title: &str,
443 content: &str,
444 tags: &[String],
445 limit: usize,
446 ) -> io::Result<Vec<MemoryDuplicateCandidate>> {
447 self.store
448 .find_duplicate_candidates(scope, project_key, r#type, title, content, tags, limit)
449 .await
450 }
451
452 #[allow(clippy::too_many_arguments)]
453 pub async fn find_duplicate_candidates_with_retrieval(
454 &self,
455 scope: MemoryScope,
456 project_key: Option<&str>,
457 r#type: Option<DurableMemoryType>,
458 title: &str,
459 content: &str,
460 tags: &[String],
461 retrieval: &MemoryRetrievalInput,
462 limit: usize,
463 ) -> io::Result<Vec<MemoryDuplicateCandidate>> {
464 self.store
465 .find_duplicate_candidates_with_retrieval(
466 scope,
467 project_key,
468 r#type,
469 title,
470 content,
471 tags,
472 retrieval,
473 limit,
474 )
475 .await
476 }
477
478 pub async fn scan_blob_candidates(
479 &self,
480 scope: MemoryScope,
481 project_key: Option<&str>,
482 min_appended_sections: usize,
483 limit: usize,
484 ) -> io::Result<BlobScanReport> {
485 self.store
486 .scan_blob_candidates(scope, project_key, min_appended_sections, limit)
487 .await
488 }
489
490 pub async fn scan_duplicate_clusters(
491 &self,
492 scope: MemoryScope,
493 project_key: Option<&str>,
494 min_score: f64,
495 max_members_per_cluster: usize,
496 limit: usize,
497 ) -> io::Result<DuplicateScanReport> {
498 self.store
499 .scan_duplicate_clusters(
500 scope,
501 project_key,
502 min_score,
503 max_members_per_cluster,
504 limit,
505 )
506 .await
507 }
508
509 pub async fn consolidate_memories(
510 &self,
511 ids: &[String],
512 preferred_project_key: Option<&str>,
513 merged: &MemorySplitPiece,
514 session_id: Option<&str>,
515 actor: &str,
516 ) -> io::Result<Option<MemoryConsolidateResult>> {
517 self.store
518 .consolidate_memories(ids, preferred_project_key, merged, session_id, actor)
519 .await
520 }
521
522 pub async fn consolidate_memories_with_retrieval(
523 &self,
524 ids: &[String],
525 preferred_project_key: Option<&str>,
526 merged: &MemorySplitPiece,
527 retrieval: &MemoryRetrievalInput,
528 session_id: Option<&str>,
529 actor: &str,
530 ) -> io::Result<Option<MemoryConsolidateResult>> {
531 self.store
532 .consolidate_memories_with_retrieval(
533 ids,
534 preferred_project_key,
535 merged,
536 retrieval,
537 session_id,
538 actor,
539 )
540 .await
541 }
542
543 #[allow(clippy::too_many_arguments)]
544 pub async fn purge_memories(
545 &self,
546 scope: MemoryScope,
547 project_key: Option<&str>,
548 filter_types: Option<&HashSet<DurableMemoryType>>,
549 filter_statuses: Option<&HashSet<DurableMemoryStatus>>,
550 filter_granularity: Option<&HashSet<TemporalGranularity>>,
551 mode: DurableMemoryStatus,
552 reason: Option<&str>,
553 ) -> io::Result<MemoryPurgeResult> {
554 self.store
555 .purge_memories(
556 scope,
557 project_key,
558 filter_types,
559 filter_statuses,
560 filter_granularity,
561 mode,
562 reason,
563 )
564 .await
565 }
566
567 #[allow(clippy::too_many_arguments)]
568 pub async fn mark_memory_contradicted(
569 &self,
570 id: &str,
571 preferred_project_key: Option<&str>,
572 contradicted_by_ids: &[String],
573 reason: Option<&str>,
574 session_id: Option<&str>,
575 actor: &str,
576 ) -> io::Result<Option<MemoryContradictionResult>> {
577 self.store
578 .mark_memory_contradicted(
579 id,
580 preferred_project_key,
581 contradicted_by_ids,
582 reason,
583 session_id,
584 actor,
585 )
586 .await
587 }
588
589 #[allow(clippy::too_many_arguments)]
590 pub async fn merge_memory(
591 &self,
592 id: &str,
593 preferred_project_key: Option<&str>,
594 content: &str,
595 tags: &[String],
596 session_id: Option<&str>,
597 actor: &str,
598 source_memory_ids: &[String],
599 ) -> io::Result<Option<MemoryMergeResult>> {
600 self.store
601 .merge_memory(
602 id,
603 preferred_project_key,
604 content,
605 tags,
606 session_id,
607 actor,
608 source_memory_ids,
609 )
610 .await
611 }
612
613 #[allow(clippy::too_many_arguments)]
614 pub async fn merge_memory_with_retrieval(
615 &self,
616 id: &str,
617 preferred_project_key: Option<&str>,
618 content: &str,
619 tags: &[String],
620 retrieval: &MemoryRetrievalInput,
621 session_id: Option<&str>,
622 actor: &str,
623 source_memory_ids: &[String],
624 ) -> io::Result<Option<MemoryMergeResult>> {
625 self.store
626 .merge_memory_with_retrieval(
627 id,
628 preferred_project_key,
629 content,
630 tags,
631 retrieval,
632 session_id,
633 actor,
634 source_memory_ids,
635 )
636 .await
637 }
638
639 pub async fn rebuild_scope(
640 &self,
641 scope: MemoryScope,
642 project_key: Option<&str>,
643 ) -> io::Result<()> {
644 self.store.rebuild_scope(scope, project_key).await
645 }
646
647 pub async fn current_scope_generation(
648 &self,
649 scope: MemoryScope,
650 project_key: Option<&str>,
651 ) -> io::Result<String> {
652 self.store
653 .current_scope_generation(scope, project_key)
654 .await
655 }
656
657 pub async fn read_dream_snapshot(
658 &self,
659 scope: MemoryScope,
660 project_key: Option<&str>,
661 ) -> io::Result<DreamReadResult> {
662 self.store.read_dream_snapshot(scope, project_key).await
663 }
664
665 pub async fn publish_dream_snapshot(
666 &self,
667 scope: MemoryScope,
668 project_key: Option<&str>,
669 source_generation: &str,
670 content: &str,
671 ) -> io::Result<DreamSnapshot> {
672 self.store
673 .publish_dream_snapshot(scope, project_key, source_generation, content)
674 .await
675 }
676
677 pub async fn list_memory_documents(
678 &self,
679 scope: MemoryScope,
680 project_key: Option<&str>,
681 ) -> io::Result<Vec<DurableMemoryDocument>> {
682 self.store
683 .list_memory_documents(scope, project_key)
684 .await
685 .map(|documents| {
686 documents
687 .into_iter()
688 .map(with_unknown_session_ranges)
689 .collect()
690 })
691 }
692
693 pub async fn count_scope_memories(
694 &self,
695 scope: MemoryScope,
696 project_key: Option<&str>,
697 ) -> io::Result<usize> {
698 self.store.count_scope_memories(scope, project_key).await
699 }
700
701 pub async fn enforce_scope_capacity(
702 &self,
703 scope: MemoryScope,
704 project_key: Option<&str>,
705 capacity: usize,
706 max_archivals: usize,
707 ) -> io::Result<Vec<String>> {
708 self.store
709 .enforce_scope_capacity(scope, project_key, capacity, max_archivals)
710 .await
711 }
712
713 pub async fn expire_stale_granularity(
714 &self,
715 scope: MemoryScope,
716 project_key: Option<&str>,
717 ) -> io::Result<Vec<String>> {
718 self.store
719 .expire_stale_granularity(scope, project_key)
720 .await
721 }
722}
723
724#[cfg(test)]
725mod data_root_tests {
726 use super::{resolve_jiandu_data_root, JianduDataRoot, BAMBOO_JIANDU_DATA_DIR_ENV};
727 use std::ffi::OsString;
728 use std::path::PathBuf;
729
730 #[test]
731 fn absent_override_selects_the_canonical_default() {
732 assert_eq!(
733 resolve_jiandu_data_root(None).unwrap(),
734 JianduDataRoot::CanonicalDefault
735 );
736 }
737
738 #[test]
739 fn absolute_override_is_preserved_exactly() {
740 let root = std::env::temp_dir().join("bamboo-explicit-jiandu-root");
741 assert!(root.is_absolute());
742 assert_eq!(
743 resolve_jiandu_data_root(Some(root.clone().into_os_string())).unwrap(),
744 JianduDataRoot::Explicit(root)
745 );
746 }
747
748 #[test]
749 fn empty_override_fails_closed() {
750 let error = resolve_jiandu_data_root(Some(OsString::new())).unwrap_err();
751 assert!(error.contains(BAMBOO_JIANDU_DATA_DIR_ENV));
752 assert!(error.contains("non-empty absolute path"));
753 }
754
755 #[test]
756 fn relative_override_fails_closed() {
757 let error =
758 resolve_jiandu_data_root(Some(OsString::from(PathBuf::from("relative/jiandu"))))
759 .unwrap_err();
760 assert!(error.contains(BAMBOO_JIANDU_DATA_DIR_ENV));
761 assert!(error.contains("absolute path"));
762 }
763}
764
765pub async fn shortlist_relevant_memories(
768 store: &MemoryStore,
769 project_key: Option<&str>,
770 query: &str,
771 options: &MemoryRecallOptions,
772) -> io::Result<Vec<MemoryRecallCandidate>> {
773 jiandu_memory::memory_store::shortlist_relevant_memories(
774 &store.store,
775 project_key,
776 query,
777 options,
778 )
779 .await
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use tempfile::tempdir;
786
787 async fn write_durable(
788 store: &MemoryStore,
789 scope: MemoryScope,
790 project_key: Option<&str>,
791 title: &str,
792 body: &str,
793 ) -> DurableMemoryDocument {
794 store
795 .write_memory(
796 scope,
797 project_key,
798 if scope == MemoryScope::Project {
799 DurableMemoryType::Project
800 } else {
801 DurableMemoryType::Reference
802 },
803 title,
804 body,
805 &["facade-test".to_string()],
806 Some("session-test"),
807 "facade-test",
808 false,
809 None,
810 )
811 .await
812 .expect("write durable memory")
813 }
814
815 #[test]
816 fn default_root_is_dot_jiandu_under_home() {
817 let expected =
818 dirs::home_dir().map_or_else(|| PathBuf::from(".jiandu"), |home| home.join(".jiandu"));
819 assert_eq!(MemoryStore::default_data_dir(), expected);
820 }
821
822 #[tokio::test]
823 async fn session_global_and_typed_project_round_trip_with_scope_isolation() {
824 let directory = tempdir().expect("tempdir");
825 let store = MemoryStore::new(directory.path());
826
827 store
828 .write_session_topic("session_1", DEFAULT_SESSION_TOPIC, "session note")
829 .await
830 .expect("write session note");
831 assert_eq!(
832 store
833 .read_session_topic("session_1", DEFAULT_SESSION_TOPIC)
834 .await
835 .expect("read session note")
836 .as_deref(),
837 Some("session note")
838 );
839
840 let global = write_durable(
841 &store,
842 MemoryScope::Global,
843 None,
844 "Global decision",
845 "Prefer deterministic lexical memory.",
846 )
847 .await;
848
849 let project_id =
850 bamboo_domain::ProjectId::parse("project_alpha").expect("valid Bamboo ProjectId");
851 let project_store = store.for_project(&project_id);
852 let project = write_durable(
853 &project_store,
854 MemoryScope::Project,
855 Some(project_id.as_str()),
856 "Project decision",
857 "Project alpha uses the Jiandu facade.",
858 )
859 .await;
860
861 assert!(project
862 .path
863 .starts_with(directory.path().join("projects/project_alpha/memory/v1")));
864 assert_eq!(
865 store
866 .get_memory(&global.frontmatter.id, None)
867 .await
868 .expect("read global")
869 .expect("global exists")
870 .body,
871 global.body
872 );
873 assert!(
874 store
875 .get_memory(&project.frontmatter.id, None)
876 .await
877 .expect("unscoped lookup")
878 .is_none(),
879 "unscoped lookup must not scan Projects"
880 );
881 assert!(project_store
882 .get_memory(&project.frontmatter.id, Some(project_id.as_str()))
883 .await
884 .expect("typed Project lookup")
885 .is_some());
886
887 let other_id =
888 bamboo_domain::ProjectId::parse("project_beta").expect("valid Bamboo ProjectId");
889 assert!(store
890 .for_project(&other_id)
891 .get_memory(&project.frontmatter.id, Some(other_id.as_str()))
892 .await
893 .expect("unrelated Project lookup")
894 .is_none());
895 }
896
897 #[tokio::test]
898 async fn legacy_session_ranges_are_unknown_without_rewriting_memory() {
899 let directory = tempdir().expect("tempdir");
900 let store = MemoryStore::new(directory.path());
901 let project_id = bamboo_domain::ProjectId::parse("legacy_ranges").unwrap();
902 for scope in [MemoryScope::Global, MemoryScope::Project] {
903 let (store, project_key) = if scope == MemoryScope::Project {
904 (store.for_project(&project_id), Some(project_id.as_str()))
905 } else {
906 (store.clone(), None)
907 };
908 let legacy = store
911 .store
912 .write_memory_with_retrieval_and_source_range(
913 scope,
914 project_key,
915 DurableMemoryType::Reference,
916 "Legacy source",
917 "Preserve this user memory.",
918 &[],
919 &MemoryRetrievalInput::default(),
920 Some("legacy-session"),
921 "user",
922 false,
923 None,
924 &["edited-or-deleted-message".to_string()],
925 )
926 .await
927 .unwrap();
928 assert_eq!(legacy.frontmatter.sources[0].message_range.len(), 1);
929 let bytes_before = tokio::fs::read(&legacy.path).await.unwrap();
930 let mut expected = legacy.clone();
931 expected.frontmatter.sources[0].message_range.clear();
932 assert_eq!(
933 store
934 .get_memory(&legacy.frontmatter.id, project_key)
935 .await
936 .unwrap(),
937 Some(expected.clone()),
938 );
939 assert_eq!(
940 store
941 .list_memory_documents(scope, project_key)
942 .await
943 .unwrap(),
944 vec![expected],
945 );
946 assert_eq!(tokio::fs::read(&legacy.path).await.unwrap(), bytes_before);
947 let archived = store
948 .archive_memory(
949 &legacy.frontmatter.id,
950 project_key,
951 DurableMemoryStatus::Archived,
952 Some("test archive"),
953 )
954 .await
955 .unwrap()
956 .unwrap();
957 assert!(archived.frontmatter.sources[0].message_range.is_empty());
958 assert_eq!(archived.frontmatter.sources[0].id, "legacy-session");
959 assert_eq!(archived.body, legacy.body);
960 }
961 }
962
963 #[tokio::test]
964 async fn inspect_reads_dream_timestamp_from_the_same_scope() {
965 let directory = tempdir().expect("tempdir");
966 let store = MemoryStore::new(directory.path());
967 write_durable(
968 &store,
969 MemoryScope::Global,
970 None,
971 "Global fact",
972 "Global memory remains separate.",
973 )
974 .await;
975
976 let global_generation = store
977 .current_scope_generation(MemoryScope::Global, None)
978 .await
979 .expect("global generation");
980 let global_dream = store
981 .publish_dream_snapshot(
982 MemoryScope::Global,
983 None,
984 &global_generation,
985 "Global orientation",
986 )
987 .await
988 .expect("publish global Dream");
989
990 let project_id =
991 bamboo_domain::ProjectId::parse("project_dream").expect("valid Bamboo ProjectId");
992 let project_store = store.for_project(&project_id);
993 write_durable(
994 &project_store,
995 MemoryScope::Project,
996 Some(project_id.as_str()),
997 "Project fact",
998 "Project memory remains separate.",
999 )
1000 .await;
1001
1002 let before_project_dream = project_store
1003 .inspect_scope(MemoryScope::Project, Some(project_id.as_str()))
1004 .await
1005 .expect("inspect Project before Dream");
1006 assert_eq!(before_project_dream.last_dream_at, None);
1007
1008 let project_generation = project_store
1009 .current_scope_generation(MemoryScope::Project, Some(project_id.as_str()))
1010 .await
1011 .expect("Project generation");
1012 let project_dream = project_store
1013 .publish_dream_snapshot(
1014 MemoryScope::Project,
1015 Some(project_id.as_str()),
1016 &project_generation,
1017 "Project orientation",
1018 )
1019 .await
1020 .expect("publish Project Dream");
1021
1022 assert_eq!(
1023 store
1024 .inspect_scope(MemoryScope::Global, None)
1025 .await
1026 .expect("inspect Global")
1027 .last_dream_at,
1028 Some(global_dream.generated_at)
1029 );
1030 assert_eq!(
1031 project_store
1032 .inspect_scope(MemoryScope::Project, Some(project_id.as_str()))
1033 .await
1034 .expect("inspect Project")
1035 .last_dream_at,
1036 Some(project_dream.generated_at)
1037 );
1038 }
1039
1040 #[tokio::test]
1041 async fn dream_cold_success_stale_and_failed_cas_preserve_prior_snapshot() {
1042 let directory = tempdir().expect("tempdir");
1043 let store = MemoryStore::new(directory.path());
1044
1045 let cold = store
1046 .read_dream_snapshot(MemoryScope::Global, None)
1047 .await
1048 .expect("read cold Dream");
1049 assert!(cold.snapshot.is_none());
1050 assert!(!cold.stale);
1051
1052 let initial = store
1053 .publish_dream_snapshot(
1054 MemoryScope::Global,
1055 None,
1056 &cold.current_generation,
1057 "Initial complete orientation",
1058 )
1059 .await
1060 .expect("publish initial Dream");
1061 let fresh = store
1062 .read_dream_snapshot(MemoryScope::Global, None)
1063 .await
1064 .expect("read fresh Dream");
1065 assert_eq!(fresh.snapshot.as_ref(), Some(&initial));
1066 assert!(!fresh.stale);
1067
1068 write_durable(
1069 &store,
1070 MemoryScope::Global,
1071 None,
1072 "Concurrent fact",
1073 "Canonical memory changed after synthesis began.",
1074 )
1075 .await;
1076 let stale = store
1077 .read_dream_snapshot(MemoryScope::Global, None)
1078 .await
1079 .expect("read stale Dream");
1080 assert!(stale.stale);
1081 assert_eq!(stale.snapshot.as_ref(), Some(&initial));
1082
1083 let error = store
1084 .publish_dream_snapshot(
1085 MemoryScope::Global,
1086 None,
1087 &cold.current_generation,
1088 "Must not replace the complete snapshot",
1089 )
1090 .await
1091 .expect_err("stale generation must fail CAS");
1092 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
1093 assert_eq!(
1094 store
1095 .read_dream_snapshot(MemoryScope::Global, None)
1096 .await
1097 .expect("read preserved Dream")
1098 .snapshot,
1099 Some(initial)
1100 );
1101 }
1102
1103 #[tokio::test]
1104 async fn rebuild_preserves_a_complete_dream_snapshot() {
1105 let directory = tempdir().expect("tempdir");
1106 let store = MemoryStore::new(directory.path());
1107 write_durable(
1108 &store,
1109 MemoryScope::Global,
1110 None,
1111 "Rebuild fact",
1112 "Derived indexes can rebuild without replacing Dream.",
1113 )
1114 .await;
1115 let generation = store
1116 .current_scope_generation(MemoryScope::Global, None)
1117 .await
1118 .expect("generation");
1119 let dream = store
1120 .publish_dream_snapshot(
1121 MemoryScope::Global,
1122 None,
1123 &generation,
1124 "Stable complete orientation",
1125 )
1126 .await
1127 .expect("publish Dream");
1128
1129 store
1130 .rebuild_scope(MemoryScope::Global, None)
1131 .await
1132 .expect("rebuild scope");
1133 let after = store
1134 .read_dream_snapshot(MemoryScope::Global, None)
1135 .await
1136 .expect("read Dream after rebuild");
1137 assert!(!after.stale);
1138 assert_eq!(after.snapshot, Some(dream));
1139 }
1140}