1use chrono::{DateTime, TimeZone, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum MemoryKind {
17 #[default]
19 LongTerm,
20 ShortTerm,
22}
23
24impl std::fmt::Display for MemoryKind {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 Self::LongTerm => write!(f, "long_term"),
28 Self::ShortTerm => write!(f, "short_term"),
29 }
30 }
31}
32
33impl std::str::FromStr for MemoryKind {
34 type Err = String;
35
36 fn from_str(value: &str) -> Result<Self, Self::Err> {
37 match value.trim().to_ascii_lowercase().as_str() {
38 "long_term" | "long-term" | "longterm" => Ok(Self::LongTerm),
39 "short_term" | "short-term" | "shortterm" => Ok(Self::ShortTerm),
40 _ => Err(format!("Unknown memory kind: {value}")),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum MemoryType {
49 Procedural,
51 Semantic,
53 #[default]
55 Episodic,
56 Resource,
58 Decision,
60 Blocker,
62 Handoff,
64 Reflection,
67}
68
69impl std::fmt::Display for MemoryType {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 Self::Procedural => write!(f, "procedural"),
73 Self::Semantic => write!(f, "semantic"),
74 Self::Episodic => write!(f, "episodic"),
75 Self::Resource => write!(f, "resource"),
76 Self::Decision => write!(f, "decision"),
77 Self::Blocker => write!(f, "blocker"),
78 Self::Handoff => write!(f, "handoff"),
79 Self::Reflection => write!(f, "reflection"),
80 }
81 }
82}
83
84impl std::str::FromStr for MemoryType {
85 type Err = String;
86
87 fn from_str(value: &str) -> Result<Self, Self::Err> {
88 match value.trim().to_ascii_lowercase().as_str() {
89 "procedural" => Ok(Self::Procedural),
90 "semantic" => Ok(Self::Semantic),
91 "episodic" => Ok(Self::Episodic),
92 "resource" => Ok(Self::Resource),
93 "decision" => Ok(Self::Decision),
94 "blocker" => Ok(Self::Blocker),
95 "handoff" => Ok(Self::Handoff),
96 "reflection" => Ok(Self::Reflection),
97 _ => Err(format!("Unknown memory type: {value}")),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum MemoryScope {
106 Task,
108 #[default]
110 Session,
111 Directive,
113 Workspace,
115 Repository,
117}
118
119impl std::fmt::Display for MemoryScope {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 Self::Task => write!(f, "task"),
123 Self::Session => write!(f, "session"),
124 Self::Directive => write!(f, "directive"),
125 Self::Workspace => write!(f, "workspace"),
126 Self::Repository => write!(f, "repository"),
127 }
128 }
129}
130
131impl std::str::FromStr for MemoryScope {
132 type Err = String;
133
134 fn from_str(value: &str) -> Result<Self, Self::Err> {
135 match value.trim().to_ascii_lowercase().as_str() {
136 "task" => Ok(Self::Task),
137 "session" => Ok(Self::Session),
138 "directive" => Ok(Self::Directive),
139 "workspace" => Ok(Self::Workspace),
140 "repository" | "repo" => Ok(Self::Repository),
141 _ => Err(format!("Unknown memory scope: {value}")),
142 }
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum ReflectionMemoryState {
150 Active,
152 Decayed,
154 NeedsReview,
156 Archived,
158}
159
160impl std::fmt::Display for ReflectionMemoryState {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::Active => write!(f, "active"),
164 Self::Decayed => write!(f, "decayed"),
165 Self::NeedsReview => write!(f, "needs_review"),
166 Self::Archived => write!(f, "archived"),
167 }
168 }
169}
170
171impl std::str::FromStr for ReflectionMemoryState {
172 type Err = String;
173
174 fn from_str(value: &str) -> Result<Self, Self::Err> {
175 match value.trim().to_ascii_lowercase().as_str() {
176 "active" => Ok(Self::Active),
177 "decayed" => Ok(Self::Decayed),
178 "needs_review" | "needs-review" | "needs review" => Ok(Self::NeedsReview),
179 "archived" => Ok(Self::Archived),
180 _ => Err(format!("Unknown reflection memory state: {value}")),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum MemoryGovernanceState {
189 #[default]
191 Active,
192 Pinned,
194 NeedsReview,
196 Superseded,
198 Archived,
200}
201
202impl std::fmt::Display for MemoryGovernanceState {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 match self {
205 Self::Active => write!(f, "active"),
206 Self::Pinned => write!(f, "pinned"),
207 Self::NeedsReview => write!(f, "needs_review"),
208 Self::Superseded => write!(f, "superseded"),
209 Self::Archived => write!(f, "archived"),
210 }
211 }
212}
213
214impl std::str::FromStr for MemoryGovernanceState {
215 type Err = String;
216
217 fn from_str(value: &str) -> Result<Self, Self::Err> {
218 match value.trim().to_ascii_lowercase().as_str() {
219 "active" => Ok(Self::Active),
220 "pinned" => Ok(Self::Pinned),
221 "needs_review" | "needs-review" | "needs review" => Ok(Self::NeedsReview),
222 "superseded" | "supersede" => Ok(Self::Superseded),
223 "archived" => Ok(Self::Archived),
224 _ => Err(format!("Unknown memory governance state: {value}")),
225 }
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum MemoryGovernanceRelationship {
233 Duplicate,
235 ConflictsWith,
237 SupersededBy,
239}
240
241impl std::fmt::Display for MemoryGovernanceRelationship {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::Duplicate => write!(f, "duplicate"),
245 Self::ConflictsWith => write!(f, "conflicts_with"),
246 Self::SupersededBy => write!(f, "superseded_by"),
247 }
248 }
249}
250
251impl std::str::FromStr for MemoryGovernanceRelationship {
252 type Err = String;
253
254 fn from_str(value: &str) -> Result<Self, Self::Err> {
255 match value.trim().to_ascii_lowercase().as_str() {
256 "duplicate" => Ok(Self::Duplicate),
257 "conflicts_with" | "conflicts-with" | "conflict" => Ok(Self::ConflictsWith),
258 "superseded_by" | "superseded-by" | "superseded" => Ok(Self::SupersededBy),
259 _ => Err(format!("Unknown governance relationship: {value}")),
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct MemoryGovernanceSuggestion {
267 pub entry_id: String,
269 pub relationship: MemoryGovernanceRelationship,
271 pub confidence: f32,
273 pub rationale: String,
275}
276
277#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub struct MemoryGovernanceRefreshReport {
280 pub entries_scanned: usize,
282 pub updated_entries: usize,
284 pub duplicate_suggestions: usize,
286 pub conflict_suggestions: usize,
288 pub superseded_suggestions: usize,
290}
291
292#[derive(Debug, Clone)]
294pub struct MemoryBankQuery {
295 pub text: Option<String>,
297 pub limit: usize,
299 pub kinds: Vec<MemoryKind>,
301 pub memory_types: Vec<MemoryType>,
303 pub scopes: Vec<MemoryScope>,
305 pub session_id: Option<String>,
307 pub task_id: Option<String>,
309 pub directive_id: Option<String>,
311 pub agent_id: Option<String>,
313 pub category: Option<String>,
315 pub tags: Vec<String>,
317 pub min_confidence: Option<f32>,
319 pub include_archived: bool,
321}
322
323impl Default for MemoryBankQuery {
324 fn default() -> Self {
325 Self {
326 text: None,
327 limit: 10,
328 kinds: Vec::new(),
329 memory_types: Vec::new(),
330 scopes: Vec::new(),
331 session_id: None,
332 task_id: None,
333 directive_id: None,
334 agent_id: None,
335 category: None,
336 tags: Vec::new(),
337 min_confidence: None,
338 include_archived: false,
339 }
340 }
341}
342
343impl MemoryBankQuery {
344 pub fn text(text: impl Into<String>) -> Self {
346 Self {
347 text: Some(text.into()),
348 ..Self::default()
349 }
350 }
351
352 pub fn with_limit(mut self, limit: usize) -> Self {
354 self.limit = limit;
355 self
356 }
357
358 pub fn with_scope(mut self, scope: MemoryScope) -> Self {
360 self.scopes.push(scope);
361 self
362 }
363
364 pub fn with_memory_type(mut self, memory_type: MemoryType) -> Self {
366 self.memory_types.push(memory_type);
367 self
368 }
369
370 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
372 self.session_id = Some(session_id.into());
373 self
374 }
375
376 pub fn with_task(mut self, task_id: impl Into<String>) -> Self {
378 self.task_id = Some(task_id.into());
379 self
380 }
381
382 pub fn with_directive(mut self, directive_id: impl Into<String>) -> Self {
384 self.directive_id = Some(directive_id.into());
385 self
386 }
387
388 pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
390 self.agent_id = Some(agent_id.into());
391 self
392 }
393
394 pub fn with_category(mut self, category: impl Into<String>) -> Self {
396 self.category = Some(category.into());
397 self
398 }
399
400 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
402 self.tags = tags;
403 self
404 }
405
406 pub fn with_min_confidence(mut self, confidence: f32) -> Self {
408 self.min_confidence = Some(confidence);
409 self
410 }
411
412 pub fn include_archived(mut self) -> Self {
414 self.include_archived = true;
415 self
416 }
417}
418
419#[derive(Debug, Clone)]
421pub struct MemorySearchResult {
422 pub entry: MemoryBankEntry,
424 pub score: f32,
426 pub matched_fields: Vec<String>,
428}
429
430#[derive(Debug, Error)]
456pub enum MemoryBankError {
457 #[error("I/O error: {0}")]
459 Io(#[from] std::io::Error),
460 #[error("Parse error: {0}")]
462 Parse(String),
463 #[error("Memory bank directory not found: {0}")]
465 DirectoryNotFound(PathBuf),
466
467 #[error("Invalid memory bank entry path: {file_path} (expected under {memory_dir})")]
469 InvalidEntryPath {
470 file_path: PathBuf,
471 memory_dir: PathBuf,
472 },
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct MemoryBankEntry {
497 pub timestamp: DateTime<Utc>,
499 #[serde(default)]
501 pub memory_kind: MemoryKind,
502 #[serde(default)]
504 pub memory_type: MemoryType,
505 #[serde(default)]
507 pub scope: MemoryScope,
508 pub session_id: String,
510 pub category: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub task_id: Option<String>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub reflection_id: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub strategy_key: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub reflection_state: Option<ReflectionMemoryState>,
524 #[serde(default, skip_serializing_if = "is_zero_u16")]
526 pub success_count: u16,
527 #[serde(default, skip_serializing_if = "is_zero_u16")]
529 pub failure_count: u16,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub directive_id: Option<String>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub agent_id: Option<String>,
536 #[serde(default, skip_serializing_if = "is_default_governance_state")]
538 pub governance_state: MemoryGovernanceState,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub governance_note: Option<String>,
542 #[serde(default, skip_serializing_if = "Vec::is_empty")]
544 pub governance_suggestions: Vec<MemoryGovernanceSuggestion>,
545 #[serde(default, skip_serializing_if = "Vec::is_empty")]
547 pub tags: Vec<String>,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub promoted_from_session_id: Option<String>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub promotion_reason: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub outcome_summary: Option<String>,
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
559 pub outcome_labels: Vec<String>,
560 #[serde(default = "default_memory_confidence")]
562 pub confidence: f32,
563 pub summary: String,
565 pub content: String,
567 #[serde(skip)]
569 pub file_path: Option<PathBuf>,
570}
571
572impl PartialEq for MemoryBankEntry {
573 fn eq(&self, other: &Self) -> bool {
574 self.timestamp == other.timestamp
577 && self.memory_kind == other.memory_kind
578 && self.memory_type == other.memory_type
579 && self.scope == other.scope
580 && self.session_id == other.session_id
581 && self.category == other.category
582 && self.task_id == other.task_id
583 && self.reflection_id == other.reflection_id
584 && self.strategy_key == other.strategy_key
585 && self.reflection_state == other.reflection_state
586 && self.success_count == other.success_count
587 && self.failure_count == other.failure_count
588 && self.directive_id == other.directive_id
589 && self.agent_id == other.agent_id
590 && self.governance_state == other.governance_state
591 && self.governance_note == other.governance_note
592 && self.governance_suggestions == other.governance_suggestions
593 && self.tags == other.tags
594 && self.promoted_from_session_id == other.promoted_from_session_id
595 && self.promotion_reason == other.promotion_reason
596 && self.outcome_summary == other.outcome_summary
597 && self.outcome_labels == other.outcome_labels
598 && (self.confidence - other.confidence).abs() < f32::EPSILON
599 && self.summary == other.summary
600 && self.content == other.content
601 }
602}
603
604impl Eq for MemoryBankEntry {}
605
606impl MemoryBankEntry {
607 pub fn new(session_id: String, summary: String, content: String) -> Self {
627 Self {
628 timestamp: Utc::now(),
629 memory_kind: MemoryKind::LongTerm,
630 memory_type: MemoryType::Episodic,
631 scope: MemoryScope::Session,
632 session_id,
633 category: None,
634 task_id: None,
635 reflection_id: None,
636 strategy_key: None,
637 reflection_state: None,
638 success_count: 0,
639 failure_count: 0,
640 directive_id: None,
641 agent_id: None,
642 governance_state: MemoryGovernanceState::Active,
643 governance_note: None,
644 governance_suggestions: Vec::new(),
645 tags: Vec::new(),
646 promoted_from_session_id: None,
647 promotion_reason: None,
648 outcome_summary: None,
649 outcome_labels: Vec::new(),
650 confidence: default_memory_confidence(),
651 summary,
652 content,
653 file_path: None,
654 }
655 }
656
657 pub fn with_memory_type(mut self, memory_type: MemoryType) -> Self {
659 self.memory_type = memory_type;
660 self
661 }
662
663 pub fn with_scope(mut self, scope: MemoryScope) -> Self {
665 self.scope = scope;
666 self
667 }
668
669 pub fn with_category(mut self, category: impl Into<String>) -> Self {
671 self.category = Some(category.into());
672 self
673 }
674
675 pub fn with_provenance(
677 mut self,
678 task_id: Option<String>,
679 directive_id: Option<String>,
680 agent_id: Option<String>,
681 ) -> Self {
682 self.task_id = task_id;
683 self.directive_id = directive_id;
684 self.agent_id = agent_id;
685 self
686 }
687
688 pub fn with_reflection_id(mut self, reflection_id: impl Into<String>) -> Self {
690 self.reflection_id = Some(reflection_id.into());
691 self
692 }
693
694 pub fn with_reflection_learning(
696 mut self,
697 strategy_key: impl Into<String>,
698 reflection_state: ReflectionMemoryState,
699 success_count: u16,
700 failure_count: u16,
701 ) -> Self {
702 self.strategy_key = Some(strategy_key.into());
703 self.reflection_state = Some(reflection_state);
704 self.success_count = success_count;
705 self.failure_count = failure_count;
706 self
707 }
708
709 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
711 self.tags = tags;
712 self
713 }
714
715 pub fn with_governance_state(mut self, state: MemoryGovernanceState) -> Self {
717 self.governance_state = state;
718 self
719 }
720
721 pub fn with_governance_note(mut self, note: impl Into<String>) -> Self {
723 self.governance_note = Some(note.into());
724 self
725 }
726
727 pub fn with_governance_suggestions(
729 mut self,
730 suggestions: Vec<MemoryGovernanceSuggestion>,
731 ) -> Self {
732 self.governance_suggestions = suggestions;
733 self
734 }
735
736 pub fn with_promotion(
738 mut self,
739 promoted_from_session_id: impl Into<String>,
740 promotion_reason: impl Into<String>,
741 ) -> Self {
742 self.promoted_from_session_id = Some(promoted_from_session_id.into());
743 self.promotion_reason = Some(promotion_reason.into());
744 self
745 }
746
747 pub fn with_outcome_provenance(
749 mut self,
750 outcome_summary: Option<String>,
751 outcome_labels: Vec<String>,
752 ) -> Self {
753 self.outcome_summary = outcome_summary.filter(|value| !value.trim().is_empty());
754 self.outcome_labels = outcome_labels;
755 self
756 }
757
758 pub fn with_confidence(mut self, confidence: f32) -> Self {
760 self.confidence = confidence.clamp(0.0, 1.0);
761 self
762 }
763
764 pub fn is_archived(&self) -> bool {
766 self.governance_state == MemoryGovernanceState::Archived
767 || self.reflection_state == Some(ReflectionMemoryState::Archived)
768 || self
769 .tags
770 .iter()
771 .any(|tag| tag.eq_ignore_ascii_case("archived"))
772 }
773
774 pub fn is_prompt_eligible_reflection(&self) -> bool {
776 !matches!(
777 self.reflection_state,
778 Some(ReflectionMemoryState::NeedsReview | ReflectionMemoryState::Archived)
779 )
780 }
781
782 pub fn to_markdown(&self) -> String {
798 let category_line = self
799 .category
800 .as_deref()
801 .map(|c| format!("**Category**: {}\n", c))
802 .unwrap_or_default();
803 let task_id_line = self
804 .task_id
805 .as_deref()
806 .map(|value| format!("**Task ID**: {}\n", value))
807 .unwrap_or_default();
808 let reflection_id_line = self
809 .reflection_id
810 .as_deref()
811 .map(|value| format!("**Reflection ID**: {}\n", value))
812 .unwrap_or_default();
813 let strategy_key_line = self
814 .strategy_key
815 .as_deref()
816 .map(|value| format!("**Strategy Key**: {}\n", value))
817 .unwrap_or_default();
818 let reflection_state_line = self
819 .reflection_state
820 .map(|value| format!("**Reflection State**: {}\n", value))
821 .unwrap_or_default();
822 let success_count_line = if self.success_count > 0 {
823 format!("**Success Count**: {}\n", self.success_count)
824 } else {
825 String::new()
826 };
827 let failure_count_line = if self.failure_count > 0 {
828 format!("**Failure Count**: {}\n", self.failure_count)
829 } else {
830 String::new()
831 };
832 let directive_id_line = self
833 .directive_id
834 .as_deref()
835 .map(|value| format!("**Directive ID**: {}\n", value))
836 .unwrap_or_default();
837 let agent_id_line = self
838 .agent_id
839 .as_deref()
840 .map(|value| format!("**Agent ID**: {}\n", value))
841 .unwrap_or_default();
842 let governance_state_line = if self.governance_state == MemoryGovernanceState::Active {
843 String::new()
844 } else {
845 format!("**Governance State**: {}\n", self.governance_state)
846 };
847 let governance_note_line = self
848 .governance_note
849 .as_deref()
850 .map(|value| format!("**Governance Note**: {}\n", value))
851 .unwrap_or_default();
852 let governance_suggestions_line = if self.governance_suggestions.is_empty() {
853 String::new()
854 } else {
855 format!(
856 "**Governance Suggestions**: {}\n",
857 self.governance_suggestions
858 .iter()
859 .map(|suggestion| format!(
860 "{}|{}|{:.2}|{}",
861 suggestion.relationship,
862 suggestion.entry_id,
863 suggestion.confidence.clamp(0.0, 1.0),
864 suggestion.rationale.replace('|', "/")
865 ))
866 .collect::<Vec<_>>()
867 .join(" ; ")
868 )
869 };
870 let tags_line = if self.tags.is_empty() {
871 String::new()
872 } else {
873 format!("**Tags**: {}\n", self.tags.join(", "))
874 };
875 let promoted_from_line = self
876 .promoted_from_session_id
877 .as_deref()
878 .map(|value| format!("**Promoted From Session ID**: {}\n", value))
879 .unwrap_or_default();
880 let promotion_reason_line = self
881 .promotion_reason
882 .as_deref()
883 .map(|value| format!("**Promotion Reason**: {}\n", value))
884 .unwrap_or_default();
885 let outcome_summary_line = self
886 .outcome_summary
887 .as_deref()
888 .map(|value| format!("**Outcome Summary**: {}\n", value))
889 .unwrap_or_default();
890 let outcome_labels_line = if self.outcome_labels.is_empty() {
891 String::new()
892 } else {
893 format!("**Outcome Labels**: {}\n", self.outcome_labels.join(", "))
894 };
895 format!(
896 "# Memory Bank Entry\n\n\
897 **Timestamp**: {}\n\
898 **Memory Kind**: {}\n\
899 **Memory Type**: {}\n\
900 **Scope**: {}\n\
901 **Session ID**: {}\n\
902 {}\
903 {}\
904 {}\
905 {}\
906 {}\
907 {}\
908 {}\
909 {}\
910 {}\
911 {}\
912 {}\
913 {}\
914 {}\
915 {}\
916 {}\
917 {}\
918 {}\
919 **Confidence**: {:.2}\n\
920 **Summary**: {}\n\n\
921 ## Context\n\n\
922 {}\n",
923 self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
924 self.memory_kind,
925 self.memory_type,
926 self.scope,
927 self.session_id,
928 category_line,
929 task_id_line,
930 reflection_id_line,
931 strategy_key_line,
932 reflection_state_line,
933 success_count_line,
934 failure_count_line,
935 directive_id_line,
936 agent_id_line,
937 governance_state_line,
938 governance_note_line,
939 governance_suggestions_line,
940 tags_line,
941 promoted_from_line,
942 promotion_reason_line,
943 outcome_summary_line,
944 outcome_labels_line,
945 self.confidence,
946 self.summary,
947 self.content
948 )
949 }
950
951 pub fn from_markdown(
966 markdown: &str,
967 file_path: Option<PathBuf>,
968 ) -> Result<Self, MemoryBankError> {
969 let lines: Vec<&str> = markdown.lines().collect();
970
971 let mut timestamp = None;
972 let mut memory_kind = MemoryKind::LongTerm;
973 let mut memory_type = MemoryType::Episodic;
974 let mut scope = MemoryScope::Session;
975 let mut session_id = None;
976 let mut category: Option<String> = None;
977 let mut task_id: Option<String> = None;
978 let mut reflection_id: Option<String> = None;
979 let mut strategy_key: Option<String> = None;
980 let mut reflection_state: Option<ReflectionMemoryState> = None;
981 let mut success_count = 0;
982 let mut failure_count = 0;
983 let mut directive_id: Option<String> = None;
984 let mut agent_id: Option<String> = None;
985 let mut governance_state = MemoryGovernanceState::Active;
986 let mut governance_note: Option<String> = None;
987 let mut governance_suggestions: Vec<MemoryGovernanceSuggestion> = Vec::new();
988 let mut tags: Vec<String> = Vec::new();
989 let mut promoted_from_session_id: Option<String> = None;
990 let mut promotion_reason: Option<String> = None;
991 let mut outcome_summary: Option<String> = None;
992 let mut outcome_labels: Vec<String> = Vec::new();
993 let mut confidence = default_memory_confidence();
994 let mut summary = None;
995 let mut content_start = None;
996
997 for (i, line) in lines.iter().enumerate() {
998 if line.starts_with("**Timestamp**:") {
999 let ts_str = line.trim_start_matches("**Timestamp**:").trim();
1000 let ts_str_clean = ts_str.trim_end_matches(" UTC");
1002 timestamp =
1003 chrono::NaiveDateTime::parse_from_str(ts_str_clean, "%Y-%m-%d %H:%M:%S")
1004 .ok()
1005 .map(|ndt| Utc.from_utc_datetime(&ndt));
1006 } else if line.starts_with("**Memory Kind**:") {
1007 let value = line.trim_start_matches("**Memory Kind**:").trim();
1008 if let Ok(parsed) = value.parse() {
1009 memory_kind = parsed;
1010 }
1011 } else if line.starts_with("**Memory Type**:") {
1012 let value = line.trim_start_matches("**Memory Type**:").trim();
1013 if let Ok(parsed) = value.parse() {
1014 memory_type = parsed;
1015 }
1016 } else if line.starts_with("**Scope**:") {
1017 let value = line.trim_start_matches("**Scope**:").trim();
1018 if let Ok(parsed) = value.parse() {
1019 scope = parsed;
1020 }
1021 } else if line.starts_with("**Session ID**:") {
1022 session_id = Some(
1023 line.trim_start_matches("**Session ID**:")
1024 .trim()
1025 .to_string(),
1026 );
1027 } else if line.starts_with("**Category**:") {
1028 let v = line.trim_start_matches("**Category**:").trim();
1029 if !v.is_empty() {
1030 category = Some(v.to_string());
1031 }
1032 } else if line.starts_with("**Task ID**:") {
1033 let v = line.trim_start_matches("**Task ID**:").trim();
1034 if !v.is_empty() {
1035 task_id = Some(v.to_string());
1036 }
1037 } else if line.starts_with("**Reflection ID**:") {
1038 let v = line.trim_start_matches("**Reflection ID**:").trim();
1039 if !v.is_empty() {
1040 reflection_id = Some(v.to_string());
1041 }
1042 } else if line.starts_with("**Strategy Key**:") {
1043 let v = line.trim_start_matches("**Strategy Key**:").trim();
1044 if !v.is_empty() {
1045 strategy_key = Some(v.to_string());
1046 }
1047 } else if line.starts_with("**Reflection State**:") {
1048 let v = line.trim_start_matches("**Reflection State**:").trim();
1049 if !v.is_empty() {
1050 reflection_state = v.parse().ok();
1051 }
1052 } else if line.starts_with("**Success Count**:") {
1053 let v = line.trim_start_matches("**Success Count**:").trim();
1054 success_count = v.parse().unwrap_or(0);
1055 } else if line.starts_with("**Failure Count**:") {
1056 let v = line.trim_start_matches("**Failure Count**:").trim();
1057 failure_count = v.parse().unwrap_or(0);
1058 } else if line.starts_with("**Directive ID**:") {
1059 let v = line.trim_start_matches("**Directive ID**:").trim();
1060 if !v.is_empty() {
1061 directive_id = Some(v.to_string());
1062 }
1063 } else if line.starts_with("**Agent ID**:") {
1064 let v = line.trim_start_matches("**Agent ID**:").trim();
1065 if !v.is_empty() {
1066 agent_id = Some(v.to_string());
1067 }
1068 } else if line.starts_with("**Governance State**:") {
1069 let v = line.trim_start_matches("**Governance State**:").trim();
1070 if !v.is_empty() {
1071 governance_state = v.parse().unwrap_or(MemoryGovernanceState::Active);
1072 }
1073 } else if line.starts_with("**Governance Note**:") {
1074 let v = line.trim_start_matches("**Governance Note**:").trim();
1075 if !v.is_empty() {
1076 governance_note = Some(v.to_string());
1077 }
1078 } else if line.starts_with("**Governance Suggestions**:") {
1079 let raw = line
1080 .trim_start_matches("**Governance Suggestions**:")
1081 .trim();
1082 if !raw.is_empty() {
1083 governance_suggestions = raw
1084 .split(" ; ")
1085 .filter_map(|part| {
1086 let mut pieces = part.splitn(4, '|');
1087 let relationship = pieces.next()?.parse().ok()?;
1088 let entry_id = pieces.next()?.trim().to_string();
1089 let confidence = pieces.next()?.trim().parse::<f32>().ok()?;
1090 let rationale = pieces.next()?.trim().to_string();
1091 Some(MemoryGovernanceSuggestion {
1092 entry_id,
1093 relationship,
1094 confidence: confidence.clamp(0.0, 1.0),
1095 rationale,
1096 })
1097 })
1098 .collect();
1099 }
1100 } else if line.starts_with("**Tags**:") {
1101 let raw = line.trim_start_matches("**Tags**:").trim();
1102 if !raw.is_empty() {
1103 tags = raw
1104 .split(',')
1105 .map(str::trim)
1106 .filter(|value| !value.is_empty())
1107 .map(ToString::to_string)
1108 .collect();
1109 }
1110 } else if line.starts_with("**Promoted From Session ID**:") {
1111 let v = line
1112 .trim_start_matches("**Promoted From Session ID**:")
1113 .trim();
1114 if !v.is_empty() {
1115 promoted_from_session_id = Some(v.to_string());
1116 }
1117 } else if line.starts_with("**Promotion Reason**:") {
1118 let v = line.trim_start_matches("**Promotion Reason**:").trim();
1119 if !v.is_empty() {
1120 promotion_reason = Some(v.to_string());
1121 }
1122 } else if line.starts_with("**Outcome Summary**:") {
1123 let v = line.trim_start_matches("**Outcome Summary**:").trim();
1124 if !v.is_empty() {
1125 outcome_summary = Some(v.to_string());
1126 }
1127 } else if line.starts_with("**Outcome Labels**:") {
1128 let raw = line.trim_start_matches("**Outcome Labels**:").trim();
1129 if !raw.is_empty() {
1130 outcome_labels = raw
1131 .split(',')
1132 .map(str::trim)
1133 .filter(|value| !value.is_empty())
1134 .map(ToString::to_string)
1135 .collect();
1136 }
1137 } else if line.starts_with("**Confidence**:") {
1138 let value = line.trim_start_matches("**Confidence**:").trim();
1139 if let Ok(parsed) = value.parse::<f32>() {
1140 confidence = parsed.clamp(0.0, 1.0);
1141 }
1142 } else if line.starts_with("**Summary**:") {
1143 summary = Some(line.trim_start_matches("**Summary**:").trim().to_string());
1144 } else if line.starts_with("## Context") {
1145 content_start = Some(i + 2); break;
1147 }
1148 }
1149
1150 let timestamp =
1151 timestamp.ok_or_else(|| MemoryBankError::Parse("Missing timestamp".to_string()))?;
1152 let session_id =
1153 session_id.ok_or_else(|| MemoryBankError::Parse("Missing session ID".to_string()))?;
1154 let summary =
1155 summary.ok_or_else(|| MemoryBankError::Parse("Missing summary".to_string()))?;
1156 let content_start = content_start
1157 .ok_or_else(|| MemoryBankError::Parse("Missing context section".to_string()))?;
1158
1159 let content = lines[content_start..].join("\n");
1160
1161 Ok(Self {
1162 timestamp,
1163 memory_kind,
1164 memory_type,
1165 scope,
1166 session_id,
1167 category,
1168 task_id,
1169 reflection_id,
1170 strategy_key,
1171 reflection_state,
1172 success_count,
1173 failure_count,
1174 directive_id,
1175 agent_id,
1176 governance_state: if governance_state == MemoryGovernanceState::Active
1177 && tags.iter().any(|tag| tag.eq_ignore_ascii_case("archived"))
1178 {
1179 MemoryGovernanceState::Archived
1180 } else {
1181 governance_state
1182 },
1183 governance_note,
1184 governance_suggestions,
1185 tags,
1186 promoted_from_session_id,
1187 promotion_reason,
1188 outcome_summary,
1189 outcome_labels,
1190 confidence,
1191 summary,
1192 content,
1193 file_path,
1194 })
1195 }
1196
1197 pub fn generate_filename(&self) -> String {
1218 format!(
1219 "memory_{}_{}.md",
1220 self.timestamp.format("%Y%m%d_%H%M%S"),
1221 &self.session_id[..20.min(self.session_id.len())]
1222 )
1223 }
1224
1225 pub fn matches_query(&self, query: &MemoryBankQuery) -> bool {
1227 if !query.include_archived && self.is_archived() {
1228 return false;
1229 }
1230 if !query.kinds.is_empty() && !query.kinds.contains(&self.memory_kind) {
1231 return false;
1232 }
1233 if !query.memory_types.is_empty() && !query.memory_types.contains(&self.memory_type) {
1234 return false;
1235 }
1236 if !query.scopes.is_empty() && !query.scopes.contains(&self.scope) {
1237 return false;
1238 }
1239 if let Some(session_id) = query.session_id.as_deref()
1240 && self.session_id != session_id
1241 {
1242 return false;
1243 }
1244 if let Some(task_id) = query.task_id.as_deref()
1245 && self.task_id.as_deref() != Some(task_id)
1246 {
1247 return false;
1248 }
1249 if let Some(directive_id) = query.directive_id.as_deref()
1250 && self.directive_id.as_deref() != Some(directive_id)
1251 {
1252 return false;
1253 }
1254 if let Some(agent_id) = query.agent_id.as_deref()
1255 && self.agent_id.as_deref() != Some(agent_id)
1256 {
1257 return false;
1258 }
1259 if let Some(category) = query.category.as_deref()
1260 && self.category.as_deref() != Some(category)
1261 {
1262 return false;
1263 }
1264 if !query.tags.is_empty()
1265 && !query.tags.iter().any(|tag| {
1266 self.tags
1267 .iter()
1268 .any(|existing| existing.eq_ignore_ascii_case(tag))
1269 })
1270 {
1271 return false;
1272 }
1273 if let Some(min_confidence) = query.min_confidence
1274 && self.confidence < min_confidence
1275 {
1276 return false;
1277 }
1278
1279 let Some(text) = query
1280 .text
1281 .as_deref()
1282 .map(str::trim)
1283 .filter(|value| !value.is_empty())
1284 else {
1285 return true;
1286 };
1287
1288 let searchable_text = self.searchable_text();
1289 let text_lower = text.to_ascii_lowercase();
1290 if searchable_text.contains(&text_lower) {
1291 return true;
1292 }
1293
1294 let terms = query_terms(text);
1295 !terms.is_empty() && terms.iter().all(|term| searchable_text.contains(term))
1296 }
1297
1298 pub fn score_against_query(&self, query: &MemoryBankQuery) -> MemorySearchResult {
1300 let text = query
1301 .text
1302 .as_deref()
1303 .unwrap_or_default()
1304 .trim()
1305 .to_ascii_lowercase();
1306 let terms = query_terms(&text);
1307 let mut score = self.confidence.clamp(0.0, 1.0) * 2.0;
1308 let mut matched_fields = Vec::new();
1309
1310 if let Some(strategy_key) = self.strategy_key.as_deref()
1311 && !text.is_empty()
1312 && strategy_key.to_ascii_lowercase().contains(&text)
1313 {
1314 score += 2.0;
1315 matched_fields.push("strategy_key".to_string());
1316 }
1317
1318 if !text.is_empty() {
1319 if self.summary.to_ascii_lowercase().contains(&text) {
1320 score += 6.0;
1321 matched_fields.push("summary".to_string());
1322 }
1323 if self.content.to_ascii_lowercase().contains(&text) {
1324 score += 3.0;
1325 matched_fields.push("content".to_string());
1326 }
1327 if self
1328 .category
1329 .as_deref()
1330 .is_some_and(|value| value.to_ascii_lowercase().contains(&text))
1331 {
1332 score += 1.5;
1333 matched_fields.push("category".to_string());
1334 }
1335 if self
1336 .directive_id
1337 .as_deref()
1338 .is_some_and(|value| value.to_ascii_lowercase().contains(&text))
1339 {
1340 score += 2.5;
1341 matched_fields.push("directive_id".to_string());
1342 }
1343 if self
1344 .task_id
1345 .as_deref()
1346 .is_some_and(|value| value.to_ascii_lowercase().contains(&text))
1347 {
1348 score += 2.0;
1349 matched_fields.push("task_id".to_string());
1350 }
1351 if self
1352 .agent_id
1353 .as_deref()
1354 .is_some_and(|value| value.to_ascii_lowercase().contains(&text))
1355 {
1356 score += 1.5;
1357 matched_fields.push("agent_id".to_string());
1358 }
1359 if self
1360 .tags
1361 .iter()
1362 .any(|tag| tag.to_ascii_lowercase().contains(&text))
1363 {
1364 score += 2.5;
1365 matched_fields.push("tags".to_string());
1366 }
1367
1368 if !terms.is_empty() {
1369 let summary_lower = self.summary.to_ascii_lowercase();
1370 let content_lower = self.content.to_ascii_lowercase();
1371 let category_lower = self
1372 .category
1373 .as_deref()
1374 .unwrap_or_default()
1375 .to_ascii_lowercase();
1376 let tags_lower = self
1377 .tags
1378 .iter()
1379 .map(|tag| tag.to_ascii_lowercase())
1380 .collect::<Vec<_>>();
1381
1382 let summary_matches = terms
1383 .iter()
1384 .filter(|term| summary_lower.contains(term.as_str()))
1385 .count();
1386 let content_matches = terms
1387 .iter()
1388 .filter(|term| content_lower.contains(term.as_str()))
1389 .count();
1390 let category_matches = terms
1391 .iter()
1392 .filter(|term| category_lower.contains(term.as_str()))
1393 .count();
1394 let tag_matches = terms
1395 .iter()
1396 .filter(|term| tags_lower.iter().any(|tag| tag.contains(term.as_str())))
1397 .count();
1398
1399 score += summary_matches as f32 * 1.5;
1400 score += content_matches as f32 * 0.75;
1401 score += category_matches as f32 * 0.5;
1402 score += tag_matches as f32 * 0.75;
1403
1404 if summary_matches > 0 && !matched_fields.iter().any(|field| field == "summary") {
1405 matched_fields.push("summary".to_string());
1406 }
1407 if content_matches > 0 && !matched_fields.iter().any(|field| field == "content") {
1408 matched_fields.push("content".to_string());
1409 }
1410 if category_matches > 0 && !matched_fields.iter().any(|field| field == "category") {
1411 matched_fields.push("category".to_string());
1412 }
1413 if tag_matches > 0 && !matched_fields.iter().any(|field| field == "tags") {
1414 matched_fields.push("tags".to_string());
1415 }
1416 }
1417 }
1418
1419 if let Some(task_id) = query.task_id.as_deref()
1420 && self.task_id.as_deref() == Some(task_id)
1421 {
1422 score += 1.25;
1423 matched_fields.push("task_id".to_string());
1424 }
1425 if let Some(directive_id) = query.directive_id.as_deref()
1426 && self.directive_id.as_deref() == Some(directive_id)
1427 {
1428 score += 1.15;
1429 matched_fields.push("directive_id".to_string());
1430 }
1431 if let Some(agent_id) = query.agent_id.as_deref()
1432 && self.agent_id.as_deref() == Some(agent_id)
1433 {
1434 score += 0.75;
1435 matched_fields.push("agent_id".to_string());
1436 }
1437
1438 score += match self.scope {
1439 MemoryScope::Task => 1.15,
1440 MemoryScope::Directive => 1.0,
1441 MemoryScope::Workspace => 0.7,
1442 MemoryScope::Repository => 0.55,
1443 MemoryScope::Session => 0.35,
1444 };
1445
1446 score += match self.memory_type {
1447 MemoryType::Procedural | MemoryType::Semantic => 0.8,
1448 MemoryType::Reflection => 0.7,
1449 MemoryType::Decision | MemoryType::Handoff => 0.6,
1450 MemoryType::Blocker | MemoryType::Resource => 0.4,
1451 MemoryType::Episodic => 0.3,
1452 };
1453
1454 score += match self.reflection_state {
1455 Some(ReflectionMemoryState::Active) => 0.8,
1456 Some(ReflectionMemoryState::Decayed) => -1.8,
1457 Some(ReflectionMemoryState::NeedsReview) => -4.0,
1458 Some(ReflectionMemoryState::Archived) => -8.0,
1459 None => 0.0,
1460 };
1461 score += match self.governance_state {
1462 MemoryGovernanceState::Active => 0.0,
1463 MemoryGovernanceState::Pinned => 2.4,
1464 MemoryGovernanceState::NeedsReview => -3.4,
1465 MemoryGovernanceState::Superseded => -2.2,
1466 MemoryGovernanceState::Archived => -8.0,
1467 };
1468 for suggestion in &self.governance_suggestions {
1469 score += match suggestion.relationship {
1470 MemoryGovernanceRelationship::Duplicate => -0.25,
1471 MemoryGovernanceRelationship::ConflictsWith => -0.85,
1472 MemoryGovernanceRelationship::SupersededBy => -0.65,
1473 } * suggestion.confidence.clamp(0.0, 1.0);
1474 }
1475 score += self.success_count as f32 * 0.35;
1476 score -= self.failure_count as f32 * 0.55;
1477 score += self.outcome_labels.len() as f32 * 0.08;
1478
1479 let age_hours = (Utc::now() - self.timestamp).num_hours().max(0) as f32;
1480 let recency_boost = (72.0 - age_hours.min(72.0)) / 72.0;
1481 score += recency_boost;
1482
1483 MemorySearchResult {
1484 entry: self.clone(),
1485 score,
1486 matched_fields,
1487 }
1488 }
1489
1490 fn searchable_text(&self) -> String {
1491 format!(
1492 "{} {} {} {} {} {} {} {} {} {}",
1493 self.summary,
1494 self.content,
1495 self.category.as_deref().unwrap_or_default(),
1496 self.directive_id.as_deref().unwrap_or_default(),
1497 self.task_id.as_deref().unwrap_or_default(),
1498 self.agent_id.as_deref().unwrap_or_default(),
1499 self.strategy_key.as_deref().unwrap_or_default(),
1500 self.governance_note.as_deref().unwrap_or_default(),
1501 self.promotion_reason.as_deref().unwrap_or_default(),
1502 self.tags.join(" "),
1503 )
1504 .to_ascii_lowercase()
1505 }
1506
1507 pub fn to_prompt_section(&self, preview_chars: usize) -> String {
1509 let preview = if self.content.chars().count() > preview_chars {
1510 format!(
1511 "{}…",
1512 self.content
1513 .chars()
1514 .take(preview_chars)
1515 .collect::<String>()
1516 .trim_end()
1517 )
1518 } else {
1519 self.content.clone()
1520 };
1521
1522 let mut header_parts = vec![
1523 format!("{}", self.scope),
1524 format!("{}", self.memory_type),
1525 format!("confidence {:.2}", self.confidence),
1526 ];
1527 if let Some(directive_id) = self.directive_id.as_deref() {
1528 header_parts.push(format!("directive {directive_id}"));
1529 }
1530 if let Some(agent_id) = self.agent_id.as_deref() {
1531 header_parts.push(format!("agent {agent_id}"));
1532 }
1533 if let Some(state) = self.reflection_state {
1534 header_parts.push(format!("state {state}"));
1535 }
1536 if self.success_count > 0 || self.failure_count > 0 {
1537 header_parts.push(format!(
1538 "success {} | failure {}",
1539 self.success_count, self.failure_count
1540 ));
1541 }
1542
1543 format!(
1544 "### Memory Entry ({})\n**Summary**: {}\n**Metadata**: {}\n\n{}\n",
1545 self.timestamp.format("%Y-%m-%d %H:%M UTC"),
1546 self.summary,
1547 header_parts.join(" | "),
1548 preview
1549 )
1550 }
1551}
1552
1553fn default_memory_confidence() -> f32 {
1554 0.70
1555}
1556
1557fn is_zero_u16(value: &u16) -> bool {
1558 *value == 0
1559}
1560
1561fn query_terms(text: &str) -> Vec<String> {
1562 text.split(|ch: char| !ch.is_ascii_alphanumeric())
1563 .map(str::trim)
1564 .filter(|term| term.len() >= 2)
1565 .map(|term| term.to_ascii_lowercase())
1566 .collect()
1567}
1568
1569pub fn get_memory_bank_dir(workspace_dir: &Path) -> PathBuf {
1589 workspace_dir.join(".gestura").join("memory")
1590}
1591
1592pub async fn ensure_memory_bank_dir(workspace_dir: &Path) -> Result<PathBuf, MemoryBankError> {
1606 let dir = get_memory_bank_dir(workspace_dir);
1607 tokio::fs::create_dir_all(&dir).await?;
1608 Ok(dir)
1609}
1610
1611pub async fn save_to_memory_bank(
1649 workspace_dir: &Path,
1650 entry: &MemoryBankEntry,
1651) -> Result<PathBuf, MemoryBankError> {
1652 let dir = ensure_memory_bank_dir(workspace_dir).await?;
1653 let filename = entry.generate_filename();
1654 let file_path = dir.join(&filename);
1655
1656 let markdown = entry.to_markdown();
1657 tokio::fs::write(&file_path, markdown).await?;
1658
1659 tracing::info!(
1660 file_path = %file_path.display(),
1661 session_id = %entry.session_id,
1662 "Saved memory bank entry"
1663 );
1664
1665 Ok(file_path)
1666}
1667
1668pub async fn load_from_memory_bank(file_path: &Path) -> Result<MemoryBankEntry, MemoryBankError> {
1697 let markdown = tokio::fs::read_to_string(file_path).await?;
1698 MemoryBankEntry::from_markdown(&markdown, Some(file_path.to_path_buf()))
1699}
1700
1701pub async fn list_memory_bank(
1736 workspace_dir: &Path,
1737) -> Result<Vec<MemoryBankEntry>, MemoryBankError> {
1738 let dir = get_memory_bank_dir(workspace_dir);
1739
1740 if !dir.exists() {
1741 return Ok(Vec::new());
1742 }
1743
1744 let mut entries = Vec::new();
1745 let mut read_dir = tokio::fs::read_dir(&dir).await?;
1746
1747 while let Some(entry) = read_dir.next_entry().await? {
1748 let path = entry.path();
1749 if path.extension().and_then(|s| s.to_str()) == Some("md") {
1750 match load_from_memory_bank(&path).await {
1751 Ok(mem_entry) => entries.push(mem_entry),
1752 Err(e) => {
1753 tracing::warn!(
1754 file_path = %path.display(),
1755 error = %e,
1756 "Failed to load memory bank entry"
1757 );
1758 }
1759 }
1760 }
1761 }
1762
1763 entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
1765
1766 Ok(entries)
1767}
1768
1769async fn ensure_memory_bank_dir_exists(workspace_dir: &Path) -> Result<PathBuf, MemoryBankError> {
1770 let dir = get_memory_bank_dir(workspace_dir);
1771 if tokio::fs::try_exists(&dir).await? {
1772 Ok(dir)
1773 } else {
1774 Err(MemoryBankError::DirectoryNotFound(dir))
1775 }
1776}
1777
1778async fn validate_entry_path(
1779 workspace_dir: &Path,
1780 file_path: &Path,
1781) -> Result<PathBuf, MemoryBankError> {
1782 let dir = ensure_memory_bank_dir_exists(workspace_dir).await?;
1783 let dir_canon = tokio::fs::canonicalize(&dir).await?;
1784 let file_canon = tokio::fs::canonicalize(file_path).await?;
1785
1786 if file_canon.extension().and_then(|s| s.to_str()) != Some("md") {
1787 return Err(MemoryBankError::Parse(
1788 "Memory bank entries must be markdown (.md) files".to_string(),
1789 ));
1790 }
1791
1792 if !file_canon.starts_with(&dir_canon) {
1793 return Err(MemoryBankError::InvalidEntryPath {
1794 file_path: file_canon,
1795 memory_dir: dir_canon,
1796 });
1797 }
1798
1799 Ok(file_canon)
1800}
1801
1802async fn atomic_write_file(path: &Path, contents: &str) -> Result<(), std::io::Error> {
1803 let file_name = path
1804 .file_name()
1805 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing file name"))?
1806 .to_string_lossy();
1807
1808 let tmp_path = path.with_file_name(format!(".{file_name}.tmp-{}", std::process::id()));
1809 tokio::fs::write(&tmp_path, contents).await?;
1810
1811 match tokio::fs::rename(&tmp_path, path).await {
1812 Ok(()) => Ok(()),
1813 Err(e) => {
1814 let _ = tokio::fs::remove_file(path).await;
1817 let rename2 = tokio::fs::rename(&tmp_path, path).await;
1818 if rename2.is_err() {
1819 let _ = tokio::fs::remove_file(&tmp_path).await;
1820 }
1821 rename2.map_err(|_| e)
1822 }
1823 }
1824}
1825
1826pub async fn delete_memory_bank_entry(
1831 workspace_dir: &Path,
1832 file_path: &Path,
1833) -> Result<(), MemoryBankError> {
1834 let file_path = validate_entry_path(workspace_dir, file_path).await?;
1835 tokio::fs::remove_file(&file_path).await?;
1836 Ok(())
1837}
1838
1839pub async fn update_memory_bank_entry(
1844 workspace_dir: &Path,
1845 file_path: &Path,
1846 entry: &MemoryBankEntry,
1847) -> Result<(), MemoryBankError> {
1848 let file_path = validate_entry_path(workspace_dir, file_path).await?;
1849 let markdown = entry.to_markdown();
1850 atomic_write_file(&file_path, &markdown).await?;
1851 Ok(())
1852}
1853
1854pub async fn search_memory_bank(
1891 workspace_dir: &Path,
1892 query: &str,
1893 limit: usize,
1894) -> Result<Vec<MemoryBankEntry>, MemoryBankError> {
1895 let query = MemoryBankQuery::text(query).with_limit(limit);
1896 let results = search_memory_bank_with_query(workspace_dir, &query).await?;
1897 Ok(results.into_iter().map(|result| result.entry).collect())
1898}
1899
1900pub async fn search_memory_bank_with_query(
1902 workspace_dir: &Path,
1903 query: &MemoryBankQuery,
1904) -> Result<Vec<MemorySearchResult>, MemoryBankError> {
1905 let all_entries = list_memory_bank(workspace_dir).await?;
1906 let mut matching_entries: Vec<MemorySearchResult> = all_entries
1907 .into_iter()
1908 .filter(|entry| entry.matches_query(query))
1909 .map(|entry| entry.score_against_query(query))
1910 .collect();
1911
1912 matching_entries.sort_by(|a, b| {
1913 b.score
1914 .total_cmp(&a.score)
1915 .then_with(|| b.entry.timestamp.cmp(&a.entry.timestamp))
1916 });
1917 matching_entries.truncate(query.limit.max(1));
1918
1919 Ok(matching_entries)
1920}
1921
1922pub async fn refresh_memory_bank_governance(
1924 workspace_dir: &Path,
1925) -> Result<MemoryGovernanceRefreshReport, MemoryBankError> {
1926 let mut entries = list_memory_bank(workspace_dir).await?;
1927 let mut suggestions_by_index: Vec<Vec<MemoryGovernanceSuggestion>> =
1928 vec![Vec::new(); entries.len()];
1929
1930 for left in 0..entries.len() {
1931 if entries[left].is_archived() {
1932 continue;
1933 }
1934
1935 for right in (left + 1)..entries.len() {
1936 if entries[right].is_archived() {
1937 continue;
1938 }
1939
1940 if let Some((confidence, rationale)) =
1941 duplicate_suggestion(&entries[left], &entries[right])
1942 {
1943 let left_id = memory_entry_identifier(&entries[left]);
1944 let right_id = memory_entry_identifier(&entries[right]);
1945 suggestions_by_index[left].push(MemoryGovernanceSuggestion {
1946 entry_id: right_id.clone(),
1947 relationship: MemoryGovernanceRelationship::Duplicate,
1948 confidence,
1949 rationale: rationale.clone(),
1950 });
1951 suggestions_by_index[right].push(MemoryGovernanceSuggestion {
1952 entry_id: left_id,
1953 relationship: MemoryGovernanceRelationship::Duplicate,
1954 confidence,
1955 rationale,
1956 });
1957
1958 if let Some((older, newer, supersession_confidence, supersession_reason)) =
1959 supersession_suggestion(
1960 left,
1961 right,
1962 &entries[left],
1963 &entries[right],
1964 confidence,
1965 )
1966 {
1967 suggestions_by_index[older].push(MemoryGovernanceSuggestion {
1968 entry_id: memory_entry_identifier(&entries[newer]),
1969 relationship: MemoryGovernanceRelationship::SupersededBy,
1970 confidence: supersession_confidence,
1971 rationale: supersession_reason,
1972 });
1973 }
1974 }
1975
1976 if let Some((confidence, rationale)) =
1977 conflict_suggestion(&entries[left], &entries[right])
1978 {
1979 let left_id = memory_entry_identifier(&entries[left]);
1980 let right_id = memory_entry_identifier(&entries[right]);
1981 suggestions_by_index[left].push(MemoryGovernanceSuggestion {
1982 entry_id: right_id.clone(),
1983 relationship: MemoryGovernanceRelationship::ConflictsWith,
1984 confidence,
1985 rationale: rationale.clone(),
1986 });
1987 suggestions_by_index[right].push(MemoryGovernanceSuggestion {
1988 entry_id: left_id,
1989 relationship: MemoryGovernanceRelationship::ConflictsWith,
1990 confidence,
1991 rationale,
1992 });
1993 }
1994 }
1995 }
1996
1997 let mut report = MemoryGovernanceRefreshReport {
1998 entries_scanned: entries.len(),
1999 ..MemoryGovernanceRefreshReport::default()
2000 };
2001
2002 for (index, entry) in entries.iter_mut().enumerate() {
2003 let Some(path) = entry.file_path.clone() else {
2004 continue;
2005 };
2006 let next =
2007 deduplicate_governance_suggestions(std::mem::take(&mut suggestions_by_index[index]));
2008 report.duplicate_suggestions += next
2009 .iter()
2010 .filter(|suggestion| suggestion.relationship == MemoryGovernanceRelationship::Duplicate)
2011 .count();
2012 report.conflict_suggestions += next
2013 .iter()
2014 .filter(|suggestion| {
2015 suggestion.relationship == MemoryGovernanceRelationship::ConflictsWith
2016 })
2017 .count();
2018 report.superseded_suggestions += next
2019 .iter()
2020 .filter(|suggestion| {
2021 suggestion.relationship == MemoryGovernanceRelationship::SupersededBy
2022 })
2023 .count();
2024
2025 if entry.governance_suggestions != next {
2026 entry.governance_suggestions = next;
2027 update_memory_bank_entry(workspace_dir, &path, entry).await?;
2028 report.updated_entries += 1;
2029 }
2030 }
2031
2032 Ok(report)
2033}
2034
2035fn deduplicate_governance_suggestions(
2036 suggestions: Vec<MemoryGovernanceSuggestion>,
2037) -> Vec<MemoryGovernanceSuggestion> {
2038 let mut seen = BTreeSet::new();
2039 let mut unique = Vec::new();
2040 let mut sorted = suggestions;
2041 sorted.sort_by(|left, right| {
2042 right
2043 .confidence
2044 .total_cmp(&left.confidence)
2045 .then_with(|| left.entry_id.cmp(&right.entry_id))
2046 .then_with(|| {
2047 left.relationship
2048 .to_string()
2049 .cmp(&right.relationship.to_string())
2050 })
2051 });
2052
2053 for suggestion in sorted {
2054 let key = format!(
2055 "{}::{}",
2056 suggestion.relationship,
2057 suggestion.entry_id.to_ascii_lowercase()
2058 );
2059 if seen.insert(key) {
2060 unique.push(suggestion);
2061 }
2062 }
2063
2064 unique.truncate(6);
2065 unique
2066}
2067
2068fn duplicate_suggestion(left: &MemoryBankEntry, right: &MemoryBankEntry) -> Option<(f32, String)> {
2069 if left.memory_type != right.memory_type {
2070 return None;
2071 }
2072
2073 let same_strategy = left.strategy_key.is_some() && left.strategy_key == right.strategy_key;
2074 let summary_similarity = similarity_ratio(&left.summary, &right.summary);
2075 let content_similarity = similarity_ratio(&left.content, &right.content);
2076 let shared_context = shares_governance_context(left, right);
2077
2078 if same_strategy {
2079 return Some((
2080 0.96,
2081 "Same normalized reflection strategy key points to overlapping corrective guidance"
2082 .to_string(),
2083 ));
2084 }
2085 if summary_similarity >= 0.96 {
2086 return Some((
2087 0.93,
2088 "Nearly identical durable-memory summary suggests a duplicate record".to_string(),
2089 ));
2090 }
2091 if shared_context && summary_similarity >= 0.82 && content_similarity >= 0.55 {
2092 return Some((
2093 ((summary_similarity + content_similarity) / 2.0).clamp(0.0, 0.9),
2094 "Overlapping scope/provenance plus similar summary/content suggests duplicate durable memory"
2095 .to_string(),
2096 ));
2097 }
2098
2099 None
2100}
2101
2102fn conflict_suggestion(left: &MemoryBankEntry, right: &MemoryBankEntry) -> Option<(f32, String)> {
2103 let same_strategy = left.strategy_key.is_some() && left.strategy_key == right.strategy_key;
2104 let same_summary = similarity_ratio(&left.summary, &right.summary) >= 0.9;
2105 if !(same_strategy || same_summary) {
2106 return None;
2107 }
2108
2109 let left_polarity = outcome_polarity(left);
2110 let right_polarity = outcome_polarity(right);
2111 if left_polarity == 0 || right_polarity == 0 || left_polarity == right_polarity {
2112 return None;
2113 }
2114
2115 Some((
2116 0.92,
2117 "Downstream outcome evidence points in opposite directions for otherwise similar durable guidance"
2118 .to_string(),
2119 ))
2120}
2121
2122fn supersession_suggestion(
2123 left_index: usize,
2124 right_index: usize,
2125 left: &MemoryBankEntry,
2126 right: &MemoryBankEntry,
2127 duplicate_confidence: f32,
2128) -> Option<(usize, usize, f32, String)> {
2129 if duplicate_confidence < 0.82 {
2130 return None;
2131 }
2132
2133 let left_strength = entry_strength(left);
2134 let right_strength = entry_strength(right);
2135 let stronger = right_strength.total_cmp(&left_strength);
2136 if stronger == std::cmp::Ordering::Equal {
2137 return None;
2138 }
2139
2140 let (older, newer, _older_entry, newer_entry, strength_gap) = if stronger.is_gt() {
2141 (
2142 left_index,
2143 right_index,
2144 left,
2145 right,
2146 right_strength - left_strength,
2147 )
2148 } else {
2149 (
2150 right_index,
2151 left_index,
2152 right,
2153 left,
2154 left_strength - right_strength,
2155 )
2156 };
2157
2158 if strength_gap < 0.35 {
2159 return None;
2160 }
2161
2162 Some((
2163 older,
2164 newer,
2165 (0.7 + strength_gap.min(1.0) * 0.15).clamp(0.0, 0.95),
2166 format!(
2167 "A newer/stronger entry ('{}') appears to supersede this durable memory",
2168 newer_entry.summary
2169 ),
2170 ))
2171}
2172
2173fn entry_strength(entry: &MemoryBankEntry) -> f32 {
2174 let recency_hours = (Utc::now() - entry.timestamp).num_hours().max(0) as f32;
2175 let recency = (168.0 - recency_hours.min(168.0)) / 168.0;
2176 entry.confidence + recency * 0.35 + entry.success_count as f32 * 0.08
2177 - entry.failure_count as f32 * 0.12
2178 + match entry.governance_state {
2179 MemoryGovernanceState::Pinned => 0.45,
2180 MemoryGovernanceState::Superseded => -0.25,
2181 MemoryGovernanceState::NeedsReview => -0.35,
2182 MemoryGovernanceState::Archived => -0.75,
2183 MemoryGovernanceState::Active => 0.0,
2184 }
2185}
2186
2187fn shares_governance_context(left: &MemoryBankEntry, right: &MemoryBankEntry) -> bool {
2188 (left.task_id.is_some() && left.task_id == right.task_id)
2189 || (left.directive_id.is_some() && left.directive_id == right.directive_id)
2190 || (left.category.is_some() && left.category == right.category)
2191 || left.scope == right.scope
2192}
2193
2194fn similarity_ratio(left: &str, right: &str) -> f32 {
2195 let left_tokens = tokenize_for_similarity(left);
2196 let right_tokens = tokenize_for_similarity(right);
2197 if left_tokens.is_empty() || right_tokens.is_empty() {
2198 return 0.0;
2199 }
2200
2201 let intersection = left_tokens.intersection(&right_tokens).count() as f32;
2202 let union = left_tokens.union(&right_tokens).count() as f32;
2203 if union == 0.0 {
2204 0.0
2205 } else {
2206 intersection / union
2207 }
2208}
2209
2210fn tokenize_for_similarity(text: &str) -> BTreeSet<String> {
2211 text.to_ascii_lowercase()
2212 .split(|character: char| !character.is_ascii_alphanumeric())
2213 .map(str::trim)
2214 .filter(|token| token.len() >= 3)
2215 .map(ToString::to_string)
2216 .collect()
2217}
2218
2219fn outcome_polarity(entry: &MemoryBankEntry) -> i8 {
2220 if entry.success_count > entry.failure_count {
2221 return 1;
2222 }
2223 if entry.failure_count > entry.success_count {
2224 return -1;
2225 }
2226
2227 let labels = entry
2228 .outcome_labels
2229 .iter()
2230 .map(|label| label.to_ascii_lowercase())
2231 .collect::<Vec<_>>();
2232 let positive = labels.iter().any(|label| {
2233 label.contains("approved")
2234 || label.contains("completed")
2235 || label.contains("passed")
2236 || label.contains("success")
2237 });
2238 let negative = labels.iter().any(|label| {
2239 label.contains("failed")
2240 || label.contains("rejected")
2241 || label.contains("blocked")
2242 || label.contains("error")
2243 });
2244 match (positive, negative) {
2245 (true, false) => 1,
2246 (false, true) => -1,
2247 _ => 0,
2248 }
2249}
2250
2251fn memory_entry_identifier(entry: &MemoryBankEntry) -> String {
2252 entry
2253 .file_path
2254 .as_ref()
2255 .map(|path| path.to_string_lossy().to_string())
2256 .unwrap_or_else(|| entry.generate_filename())
2257}
2258
2259fn is_default_governance_state(value: &MemoryGovernanceState) -> bool {
2260 *value == MemoryGovernanceState::Active
2261}
2262
2263pub async fn clear_memory_bank(workspace_dir: &Path) -> Result<usize, MemoryBankError> {
2294 let dir = get_memory_bank_dir(workspace_dir);
2295
2296 if !dir.exists() {
2297 return Ok(0);
2298 }
2299
2300 let mut count = 0;
2301 let mut read_dir = tokio::fs::read_dir(&dir).await?;
2302
2303 while let Some(entry) = read_dir.next_entry().await? {
2304 let path = entry.path();
2305 if path.extension().and_then(|s| s.to_str()) == Some("md") {
2306 tokio::fs::remove_file(&path).await?;
2307 count += 1;
2308 }
2309 }
2310
2311 tracing::info!(count = count, "Cleared memory bank entries");
2312
2313 Ok(count)
2314}
2315
2316#[cfg(test)]
2317mod tests {
2318 use super::*;
2319 use tempfile::TempDir;
2320
2321 #[tokio::test]
2322 async fn test_ensure_memory_bank_dir() {
2323 let temp_dir = TempDir::new().unwrap();
2324 let workspace_path = temp_dir.path();
2325
2326 let memory_dir = ensure_memory_bank_dir(workspace_path).await.unwrap();
2328
2329 assert!(memory_dir.exists(), "Memory bank directory should exist");
2330 assert_eq!(
2331 memory_dir,
2332 workspace_path.join(".gestura").join("memory"),
2333 "Memory bank directory should be at correct path"
2334 );
2335 }
2336
2337 #[tokio::test]
2338 async fn test_save_and_load_memory_bank() {
2339 let temp_dir = TempDir::new().unwrap();
2340 let workspace_path = temp_dir.path();
2341
2342 let entry = MemoryBankEntry::new(
2344 "test-session-123".to_string(),
2345 "Implemented user authentication feature".to_string(),
2346 "User: How do I add authentication?\nAssistant: Here's how to implement JWT auth..."
2347 .to_string(),
2348 );
2349
2350 let file_path = save_to_memory_bank(workspace_path, &entry).await.unwrap();
2352
2353 assert!(file_path.exists(), "Memory bank file should exist");
2354 assert_eq!(
2355 file_path.extension().and_then(|s| s.to_str()),
2356 Some("md"),
2357 "Memory bank file should have .md extension"
2358 );
2359
2360 let loaded_entry = load_from_memory_bank(&file_path).await.unwrap();
2362
2363 assert_eq!(loaded_entry.session_id, entry.session_id);
2364 assert_eq!(loaded_entry.summary, entry.summary);
2365 assert_eq!(loaded_entry.content, entry.content);
2366 assert!(
2367 loaded_entry.file_path.is_some(),
2368 "Loaded entry should have file path"
2369 );
2370 }
2371
2372 #[tokio::test]
2373 async fn test_list_memory_bank() {
2374 let temp_dir = TempDir::new().unwrap();
2375 let workspace_path = temp_dir.path();
2376
2377 let entries = list_memory_bank(workspace_path).await.unwrap();
2379 assert_eq!(entries.len(), 0, "Memory bank should be empty initially");
2380
2381 for i in 0..3 {
2383 let entry = MemoryBankEntry::new(
2384 format!("session-unique-{:03}", i),
2385 format!("Summary {}", i),
2386 format!("Content {}", i),
2387 );
2388 save_to_memory_bank(workspace_path, &entry).await.unwrap();
2389 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2391 }
2392
2393 let entries = list_memory_bank(workspace_path).await.unwrap();
2395 assert_eq!(entries.len(), 3, "Memory bank should contain 3 entries");
2396
2397 for i in 0..entries.len() - 1 {
2399 assert!(
2400 entries[i].timestamp >= entries[i + 1].timestamp,
2401 "Entries should be sorted by timestamp (newest first)"
2402 );
2403 }
2404 }
2405
2406 #[tokio::test]
2407 async fn test_search_memory_bank() {
2408 let temp_dir = TempDir::new().unwrap();
2409 let workspace_path = temp_dir.path();
2410
2411 let entries_data = vec![
2413 (
2414 "session-auth-001",
2415 "Implemented user authentication",
2416 "Developer asked about JWT authentication and OAuth2 flows",
2417 ),
2418 (
2419 "session-db-002",
2420 "Fixed database bug",
2421 "Team reported slow queries in PostgreSQL database",
2422 ),
2423 (
2424 "session-profile-003",
2425 "Added user profile",
2426 "Client wanted to add user profile pictures and bio fields",
2427 ),
2428 ];
2429
2430 for (session_id, summary, content) in entries_data {
2431 let entry = MemoryBankEntry::new(
2432 session_id.to_string(),
2433 summary.to_string(),
2434 content.to_string(),
2435 );
2436 save_to_memory_bank(workspace_path, &entry).await.unwrap();
2437 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2439 }
2440
2441 let results = search_memory_bank(workspace_path, "authentication", 10)
2443 .await
2444 .unwrap();
2445 assert_eq!(
2446 results.len(),
2447 1,
2448 "Should find 1 entry matching 'authentication'"
2449 );
2450 assert_eq!(results[0].session_id, "session-auth-001");
2451
2452 let results = search_memory_bank(workspace_path, "user", 10)
2454 .await
2455 .unwrap();
2456 assert_eq!(results.len(), 2, "Should find 2 entries matching 'user'");
2457
2458 let results = search_memory_bank(workspace_path, "user", 1).await.unwrap();
2460 assert_eq!(results.len(), 1, "Should respect limit parameter");
2461
2462 let results = search_memory_bank(workspace_path, "nonexistent", 10)
2464 .await
2465 .unwrap();
2466 assert_eq!(
2467 results.len(),
2468 0,
2469 "Should find 0 entries for non-existent term"
2470 );
2471 }
2472
2473 #[tokio::test]
2474 async fn test_category_roundtrip_and_search() {
2475 let temp_dir = TempDir::new().unwrap();
2476 let workspace_path = temp_dir.path();
2477
2478 let mut entry = MemoryBankEntry::new(
2479 "session-cat-001".to_string(),
2480 "Entry with category".to_string(),
2481 "Some content".to_string(),
2482 );
2483 entry.category = Some("research".to_string());
2484
2485 let file_path = save_to_memory_bank(workspace_path, &entry).await.unwrap();
2486 let loaded = load_from_memory_bank(&file_path).await.unwrap();
2487 assert_eq!(loaded.category.as_deref(), Some("research"));
2488
2489 let results = search_memory_bank(workspace_path, "research", 10)
2490 .await
2491 .unwrap();
2492 assert_eq!(results.len(), 1);
2493 assert_eq!(results[0].session_id, "session-cat-001");
2494 }
2495
2496 #[tokio::test]
2497 async fn test_typed_metadata_roundtrip() {
2498 let temp_dir = TempDir::new().unwrap();
2499 let workspace_path = temp_dir.path();
2500
2501 let entry = MemoryBankEntry::new(
2502 "session-directive-001".to_string(),
2503 "Shared implementation directive".to_string(),
2504 "Use directive-scoped long-term memory for cross-agent coordination.".to_string(),
2505 )
2506 .with_memory_type(MemoryType::Procedural)
2507 .with_scope(MemoryScope::Directive)
2508 .with_category("workflow")
2509 .with_provenance(
2510 Some("task-42".to_string()),
2511 Some("directive-memory".to_string()),
2512 Some("supervisor-agent".to_string()),
2513 )
2514 .with_reflection_id("reflection-42")
2515 .with_reflection_learning("inspect-files-first", ReflectionMemoryState::Active, 2, 0)
2516 .with_tags(vec!["memory".to_string(), "coordination".to_string()])
2517 .with_promotion(
2518 "session-short-1",
2519 "Promoted after reflection because it applies across agents",
2520 )
2521 .with_outcome_provenance(
2522 Some("task_completed; review_approved; test_validation_approved".to_string()),
2523 vec![
2524 "task_completed".to_string(),
2525 "review_approved".to_string(),
2526 "test_validation_approved".to_string(),
2527 ],
2528 )
2529 .with_confidence(0.92);
2530
2531 let file_path = save_to_memory_bank(workspace_path, &entry).await.unwrap();
2532 let loaded = load_from_memory_bank(&file_path).await.unwrap();
2533
2534 assert_eq!(loaded.memory_type, MemoryType::Procedural);
2535 assert_eq!(loaded.scope, MemoryScope::Directive);
2536 assert_eq!(loaded.directive_id.as_deref(), Some("directive-memory"));
2537 assert_eq!(loaded.task_id.as_deref(), Some("task-42"));
2538 assert_eq!(loaded.reflection_id.as_deref(), Some("reflection-42"));
2539 assert_eq!(loaded.strategy_key.as_deref(), Some("inspect-files-first"));
2540 assert_eq!(loaded.reflection_state, Some(ReflectionMemoryState::Active));
2541 assert_eq!(loaded.success_count, 2);
2542 assert_eq!(loaded.failure_count, 0);
2543 assert_eq!(loaded.agent_id.as_deref(), Some("supervisor-agent"));
2544 assert_eq!(
2545 loaded.promoted_from_session_id.as_deref(),
2546 Some("session-short-1")
2547 );
2548 assert_eq!(
2549 loaded.outcome_summary.as_deref(),
2550 Some("task_completed; review_approved; test_validation_approved")
2551 );
2552 assert_eq!(
2553 loaded.outcome_labels,
2554 vec![
2555 "task_completed",
2556 "review_approved",
2557 "test_validation_approved"
2558 ]
2559 );
2560 assert_eq!(loaded.tags, vec!["memory", "coordination"]);
2561 assert!((loaded.confidence - 0.92).abs() < f32::EPSILON);
2562 }
2563
2564 #[tokio::test]
2565 async fn test_search_memory_bank_with_query_filters_and_ranks() {
2566 let temp_dir = TempDir::new().unwrap();
2567 let workspace_path = temp_dir.path();
2568
2569 let directive_entry = MemoryBankEntry::new(
2570 "session-a".to_string(),
2571 "Directive workflow memory policy".to_string(),
2572 "Share only durable subagent summaries across the directive.".to_string(),
2573 )
2574 .with_memory_type(MemoryType::Procedural)
2575 .with_scope(MemoryScope::Directive)
2576 .with_provenance(
2577 Some("task-a".to_string()),
2578 Some("directive-1".to_string()),
2579 Some("agent-a".to_string()),
2580 )
2581 .with_tags(vec!["memory".to_string(), "policy".to_string()])
2582 .with_confidence(0.95);
2583 save_to_memory_bank(workspace_path, &directive_entry)
2584 .await
2585 .unwrap();
2586
2587 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2588
2589 let session_entry = MemoryBankEntry::new(
2590 "session-b".to_string(),
2591 "Local scratch note".to_string(),
2592 "Temporary debugging note for one task.".to_string(),
2593 )
2594 .with_memory_type(MemoryType::Resource)
2595 .with_scope(MemoryScope::Session)
2596 .with_tags(vec!["debug".to_string()])
2597 .with_confidence(0.40);
2598 save_to_memory_bank(workspace_path, &session_entry)
2599 .await
2600 .unwrap();
2601
2602 let query = MemoryBankQuery::text("directive memory policy")
2603 .with_limit(5)
2604 .with_scope(MemoryScope::Directive)
2605 .with_memory_type(MemoryType::Procedural)
2606 .with_directive("directive-1")
2607 .with_min_confidence(0.70);
2608
2609 let results = search_memory_bank_with_query(workspace_path, &query)
2610 .await
2611 .unwrap();
2612
2613 assert_eq!(results.len(), 1);
2614 assert_eq!(results[0].entry.summary, "Directive workflow memory policy");
2615 assert!(results[0].score > 0.0);
2616 assert!(
2617 results[0]
2618 .matched_fields
2619 .iter()
2620 .any(|field| field == "summary")
2621 );
2622 }
2623
2624 #[tokio::test]
2625 async fn test_reflection_learning_state_filters_and_ranks() {
2626 let temp_dir = TempDir::new().unwrap();
2627 let workspace_path = temp_dir.path();
2628
2629 let active_entry = MemoryBankEntry::new(
2630 "session-reflection-a".to_string(),
2631 "Reflection: inspect files first".to_string(),
2632 "Inspect the relevant files before concluding behavior.".to_string(),
2633 )
2634 .with_memory_type(MemoryType::Reflection)
2635 .with_scope(MemoryScope::Workspace)
2636 .with_reflection_id("reflection-active")
2637 .with_reflection_learning("inspect-files-first", ReflectionMemoryState::Active, 2, 0)
2638 .with_confidence(0.92);
2639 save_to_memory_bank(workspace_path, &active_entry)
2640 .await
2641 .unwrap();
2642
2643 let decayed_entry = MemoryBankEntry::new(
2644 "session-reflection-b".to_string(),
2645 "Reflection: inspect files first".to_string(),
2646 "Inspect the relevant files before concluding behavior.".to_string(),
2647 )
2648 .with_memory_type(MemoryType::Reflection)
2649 .with_scope(MemoryScope::Workspace)
2650 .with_reflection_id("reflection-decayed")
2651 .with_reflection_learning("inspect-files-first", ReflectionMemoryState::Decayed, 0, 2)
2652 .with_confidence(0.92);
2653 save_to_memory_bank(workspace_path, &decayed_entry)
2654 .await
2655 .unwrap();
2656
2657 let archived_entry = MemoryBankEntry::new(
2658 "session-reflection-c".to_string(),
2659 "Reflection: inspect files first".to_string(),
2660 "Inspect the relevant files before concluding behavior.".to_string(),
2661 )
2662 .with_memory_type(MemoryType::Reflection)
2663 .with_scope(MemoryScope::Workspace)
2664 .with_reflection_id("reflection-archived")
2665 .with_reflection_learning("inspect-files-first", ReflectionMemoryState::Archived, 0, 3)
2666 .with_confidence(0.92);
2667 save_to_memory_bank(workspace_path, &archived_entry)
2668 .await
2669 .unwrap();
2670
2671 let results = search_memory_bank_with_query(
2672 workspace_path,
2673 &MemoryBankQuery::text("inspect files first")
2674 .with_limit(10)
2675 .with_memory_type(MemoryType::Reflection),
2676 )
2677 .await
2678 .unwrap();
2679
2680 assert_eq!(results.len(), 2);
2681 assert_eq!(
2682 results[0].entry.reflection_id.as_deref(),
2683 Some("reflection-active")
2684 );
2685 assert_eq!(
2686 results[1].entry.reflection_id.as_deref(),
2687 Some("reflection-decayed")
2688 );
2689 assert!(results[0].score > results[1].score);
2690 assert!(results[0].entry.is_prompt_eligible_reflection());
2691 assert!(results[1].entry.is_prompt_eligible_reflection());
2692 assert!(!archived_entry.matches_query(&MemoryBankQuery::text("inspect files first")));
2693 }
2694
2695 #[tokio::test]
2696 async fn test_refresh_memory_bank_governance_persists_suggestions_and_ranks_entries() {
2697 let temp_dir = TempDir::new().unwrap();
2698 let workspace_path = temp_dir.path();
2699
2700 let weaker_duplicate = MemoryBankEntry::new(
2701 "session-governance-a".to_string(),
2702 "Directive memory handoff policy".to_string(),
2703 "Store concise directive-scoped handoff notes for subagent reuse.".to_string(),
2704 )
2705 .with_memory_type(MemoryType::Procedural)
2706 .with_scope(MemoryScope::Directive)
2707 .with_provenance(
2708 Some("task-governance".to_string()),
2709 Some("directive-governance".to_string()),
2710 Some("agent-a".to_string()),
2711 )
2712 .with_confidence(0.62);
2713 let weaker_path = save_to_memory_bank(workspace_path, &weaker_duplicate)
2714 .await
2715 .unwrap();
2716
2717 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2718
2719 let pinned_duplicate = MemoryBankEntry::new(
2720 "session-governance-b".to_string(),
2721 "Directive memory handoff policy".to_string(),
2722 "Store concise directive-scoped handoff notes for subagent reuse and supervisor retrieval."
2723 .to_string(),
2724 )
2725 .with_memory_type(MemoryType::Procedural)
2726 .with_scope(MemoryScope::Directive)
2727 .with_provenance(
2728 Some("task-governance".to_string()),
2729 Some("directive-governance".to_string()),
2730 Some("agent-b".to_string()),
2731 )
2732 .with_governance_state(MemoryGovernanceState::Pinned)
2733 .with_confidence(0.95);
2734 save_to_memory_bank(workspace_path, &pinned_duplicate)
2735 .await
2736 .unwrap();
2737
2738 let successful_reflection = MemoryBankEntry::new(
2739 "session-governance-c".to_string(),
2740 "Reflection: inspect files first".to_string(),
2741 "Inspect the code before making assumptions about behavior.".to_string(),
2742 )
2743 .with_memory_type(MemoryType::Reflection)
2744 .with_scope(MemoryScope::Workspace)
2745 .with_reflection_learning("inspect-files-first", ReflectionMemoryState::Active, 3, 0)
2746 .with_outcome_provenance(
2747 Some("Approved after inspection".to_string()),
2748 vec!["approved".to_string()],
2749 );
2750 save_to_memory_bank(workspace_path, &successful_reflection)
2751 .await
2752 .unwrap();
2753
2754 let failed_reflection = MemoryBankEntry::new(
2755 "session-governance-d".to_string(),
2756 "Reflection: inspect files first".to_string(),
2757 "Inspect the code before making assumptions about behavior.".to_string(),
2758 )
2759 .with_memory_type(MemoryType::Reflection)
2760 .with_scope(MemoryScope::Workspace)
2761 .with_reflection_learning(
2762 "inspect-files-first",
2763 ReflectionMemoryState::NeedsReview,
2764 0,
2765 3,
2766 )
2767 .with_outcome_provenance(
2768 Some("Rejected without inspection".to_string()),
2769 vec!["failed".to_string()],
2770 );
2771 save_to_memory_bank(workspace_path, &failed_reflection)
2772 .await
2773 .unwrap();
2774
2775 let report = refresh_memory_bank_governance(workspace_path)
2776 .await
2777 .unwrap();
2778 assert!(report.updated_entries >= 4);
2779 assert!(report.duplicate_suggestions >= 2);
2780 assert!(report.conflict_suggestions >= 2);
2781 assert!(report.superseded_suggestions >= 1);
2782
2783 let reloaded_weaker = load_from_memory_bank(&weaker_path).await.unwrap();
2784 assert!(
2785 reloaded_weaker
2786 .governance_suggestions
2787 .iter()
2788 .any(|suggestion| {
2789 suggestion.relationship == MemoryGovernanceRelationship::Duplicate
2790 })
2791 );
2792 assert!(
2793 reloaded_weaker
2794 .governance_suggestions
2795 .iter()
2796 .any(|suggestion| {
2797 suggestion.relationship == MemoryGovernanceRelationship::SupersededBy
2798 })
2799 );
2800
2801 let ranked = search_memory_bank_with_query(
2802 workspace_path,
2803 &MemoryBankQuery::text("directive memory handoff policy")
2804 .with_limit(5)
2805 .with_directive("directive-governance")
2806 .with_memory_type(MemoryType::Procedural),
2807 )
2808 .await
2809 .unwrap();
2810 assert_eq!(ranked.len(), 2);
2811 assert_eq!(
2812 ranked[0].entry.governance_state,
2813 MemoryGovernanceState::Pinned
2814 );
2815 }
2816
2817 #[tokio::test]
2818 async fn test_update_and_delete_memory_bank_entry() {
2819 let temp_dir = TempDir::new().unwrap();
2820 let workspace_path = temp_dir.path();
2821
2822 let mut entry = MemoryBankEntry::new(
2823 "session-edit-001".to_string(),
2824 "Original summary".to_string(),
2825 "Original content".to_string(),
2826 );
2827 entry.category = Some("initial".to_string());
2828
2829 let file_path = save_to_memory_bank(workspace_path, &entry).await.unwrap();
2830
2831 let mut updated = load_from_memory_bank(&file_path).await.unwrap();
2833 updated.summary = "Updated summary".to_string();
2834 updated.content = "Updated content".to_string();
2835 updated.category = Some("updated".to_string());
2836
2837 update_memory_bank_entry(workspace_path, &file_path, &updated)
2838 .await
2839 .unwrap();
2840
2841 let reloaded = load_from_memory_bank(&file_path).await.unwrap();
2842 assert_eq!(reloaded.summary, "Updated summary");
2843 assert_eq!(reloaded.content, "Updated content");
2844 assert_eq!(reloaded.category.as_deref(), Some("updated"));
2845
2846 delete_memory_bank_entry(workspace_path, &file_path)
2848 .await
2849 .unwrap();
2850 assert!(!file_path.exists());
2851
2852 let entries = list_memory_bank(workspace_path).await.unwrap();
2853 assert_eq!(entries.len(), 0);
2854 }
2855
2856 #[tokio::test]
2857 async fn test_update_rejects_outside_memory_dir() {
2858 let temp_dir = TempDir::new().unwrap();
2859 let workspace_path = temp_dir.path();
2860
2861 let entry = MemoryBankEntry::new(
2863 "session-init".to_string(),
2864 "Init".to_string(),
2865 "Init".to_string(),
2866 );
2867 let _ = save_to_memory_bank(workspace_path, &entry).await.unwrap();
2868
2869 let outside_path = workspace_path.join("not-in-memory.md");
2870 tokio::fs::write(&outside_path, "# Not a memory entry")
2871 .await
2872 .unwrap();
2873
2874 let entry_payload = MemoryBankEntry::new(
2876 "session".to_string(),
2877 "Summary".to_string(),
2878 "Content".to_string(),
2879 );
2880
2881 let err = update_memory_bank_entry(workspace_path, &outside_path, &entry_payload)
2882 .await
2883 .unwrap_err();
2884 match err {
2885 MemoryBankError::InvalidEntryPath { .. } => {}
2886 other => panic!("Expected InvalidEntryPath, got: {other:?}"),
2887 }
2888 }
2889
2890 #[tokio::test]
2891 async fn test_clear_memory_bank() {
2892 let temp_dir = TempDir::new().unwrap();
2893 let workspace_path = temp_dir.path();
2894
2895 for i in 0..5 {
2897 let entry = MemoryBankEntry::new(
2898 format!("session-clear-{:03}", i),
2899 format!("Summary {}", i),
2900 format!("Content {}", i),
2901 );
2902 save_to_memory_bank(workspace_path, &entry).await.unwrap();
2903 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2905 }
2906
2907 let entries = list_memory_bank(workspace_path).await.unwrap();
2909 assert_eq!(entries.len(), 5, "Should have 5 entries before clear");
2910
2911 let count = clear_memory_bank(workspace_path).await.unwrap();
2913 assert_eq!(count, 5, "Should clear 5 entries");
2914
2915 let entries = list_memory_bank(workspace_path).await.unwrap();
2917 assert_eq!(entries.len(), 0, "Memory bank should be empty after clear");
2918
2919 let count = clear_memory_bank(workspace_path).await.unwrap();
2921 assert_eq!(count, 0, "Clearing empty memory bank should return 0");
2922 }
2923
2924 #[tokio::test]
2925 async fn test_markdown_format() {
2926 let temp_dir = TempDir::new().unwrap();
2927 let workspace_path = temp_dir.path();
2928
2929 let entry = MemoryBankEntry::new(
2931 "test-session".to_string(),
2932 "Test summary".to_string(),
2933 "Line 1\nLine 2\nLine 3".to_string(),
2934 );
2935
2936 let file_path = save_to_memory_bank(workspace_path, &entry).await.unwrap();
2938
2939 let markdown = tokio::fs::read_to_string(&file_path).await.unwrap();
2941
2942 println!("Generated markdown:\n{}", markdown);
2943
2944 assert!(
2946 markdown.contains("# Memory Bank Entry"),
2947 "Should have title"
2948 );
2949 assert!(
2950 markdown.contains("**Session ID**:"),
2951 "Should have session ID field"
2952 );
2953 assert!(
2954 markdown.contains("**Timestamp**:"),
2955 "Should have timestamp field"
2956 );
2957 assert!(
2958 markdown.contains("**Summary**:"),
2959 "Should have summary field"
2960 );
2961 assert!(
2962 markdown.contains("## Context"),
2963 "Should have context section"
2964 );
2965 assert!(
2966 markdown.contains("test-session"),
2967 "Should contain session ID value"
2968 );
2969 assert!(
2970 markdown.contains("Test summary"),
2971 "Should contain summary value"
2972 );
2973 assert!(
2974 markdown.contains("Line 1\nLine 2\nLine 3"),
2975 "Should contain content value"
2976 );
2977 }
2978}