Skip to main content

bamboo_memory/memory_store/
mod.rs

1//! Bamboo's narrow native facade over the Jiandu memory store.
2
3use 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/// Jiandu's inspection result plus Bamboo's flattened Dream timestamp.
26#[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
54/// Process-level opt-in used by Bamboo launchers to isolate Jiandu state.
55///
56/// Library callers remain deterministic: [`MemoryStore::with_defaults`] does
57/// not inspect this variable. Process entrypoints must resolve it explicitly
58/// and inject the resulting store into every runtime and tool consumer.
59pub const BAMBOO_JIANDU_DATA_DIR_ENV: &str = "BAMBOO_JIANDU_DATA_DIR";
60
61/// Validated selection of Bamboo's process-wide Jiandu data root.
62#[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
84/// Validate a launcher-provided Jiandu root without reading or mutating the
85/// process environment. An explicitly configured value must be non-empty and
86/// absolute so a managed launch cannot silently escape its run-owned root.
87pub 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/// Bamboo-facing memory handle. Jiandu remains private so Bamboo callers cannot
107/// bypass the facade's Project identity and inspection extensions.
108#[derive(Debug, Clone)]
109pub struct MemoryStore {
110    store: jiandu_memory::memory_store::MemoryStore,
111}
112
113/// Jiandu 0.3.3 stores message IDs without durable role/content proof. Bamboo
114/// therefore treats Session message coverage as unknown, including old records
115/// and records whose maintenance has changed the actor. Keep the source Session,
116/// memory identity and body; normalize only the returned view, never stored bytes.
117fn 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    /// Compact, read-only discovery in the Host-authorized Project only.
134    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    /// Read the native exact adopted version without a Global/Session fallback.
150    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    /// The active Jiandu canonical root, including an explicitly selected root.
168    /// External clients must share this root rather than guessing a default.
169    pub fn data_dir(&self) -> PathBuf {
170        self.store.resolver().data_dir()
171    }
172
173    /// Construct a Jiandu store rooted at an explicit data directory.
174    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    /// Construct the production store at the independent `~/.jiandu` root.
181    pub fn with_defaults() -> Self {
182        Self::new(Self::default_data_dir())
183    }
184
185    /// Resolve the canonical production data root used by [`Self::with_defaults`].
186    pub fn default_data_dir() -> PathBuf {
187        dirs::home_dir().map_or_else(|| PathBuf::from(".jiandu"), |home| home.join(".jiandu"))
188    }
189
190    /// Bind Project memory to Bamboo's first-class Project identity.
191    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
765/// Run Jiandu's deterministic lexical shortlist without exposing the composed
766/// store to Bamboo callers.
767pub 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            // Seed the previously persisted Jiandu format directly. Actor changes
909            // during maintenance cannot make ID-only Session coverage reliable.
910            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}