gestura_core_memory_bank/
memory_bank.rs

1//! Memory Bank - Persistent context storage for conversation history
2//!
3//! This module implements the Memory Bank concept inspired by Kilo Code's approach.
4//! It provides persistent storage of conversation context in human-readable markdown
5//! files that can be searched and retrieved across sessions.
6
7use chrono::{DateTime, TimeZone, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12
13/// High-level memory retention domain.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum MemoryKind {
17    /// Durable long-term memory shared across sessions/agents.
18    #[default]
19    LongTerm,
20    /// Explicitly marked short-term memory persisted for inspection or handoff.
21    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/// Typed classification for durable memory records.
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum MemoryType {
49    /// Process/workflow guidance.
50    Procedural,
51    /// Stable factual project knowledge.
52    Semantic,
53    /// Outcome or event history.
54    #[default]
55    Episodic,
56    /// Resource acquisition or references.
57    Resource,
58    /// Explicit decisions.
59    Decision,
60    /// Known blockers.
61    Blocker,
62    /// Handoff/checkpoint material.
63    Handoff,
64    /// Structured corrective reflection promoted from a failed/suboptimal agent
65    /// attempt (ERL-inspired) so future turns can retrieve and reuse it.
66    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/// Scope for targeted durable-memory retrieval.
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum MemoryScope {
106    /// Single task scope.
107    Task,
108    /// Single session scope.
109    #[default]
110    Session,
111    /// Shared multi-agent directive scope.
112    Directive,
113    /// Workspace scope.
114    Workspace,
115    /// Repository-wide scope.
116    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/// Retrieval state for reflection-derived durable memories.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum ReflectionMemoryState {
150    /// Reflection is healthy and can be injected normally.
151    Active,
152    /// Reflection has repeated negative evidence and should be downranked.
153    Decayed,
154    /// Reflection conflicts with observed evidence and needs review.
155    NeedsReview,
156    /// Reflection should no longer be retrieved for prompt injection.
157    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/// General governance state for durable memory curation.
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum MemoryGovernanceState {
189    /// Entry is healthy and retrieved normally.
190    #[default]
191    Active,
192    /// Entry was explicitly pinned by an operator and should be preferred.
193    Pinned,
194    /// Entry needs operator review before it should be trusted broadly.
195    NeedsReview,
196    /// Entry is retained for auditability but has been superseded by a stronger record.
197    Superseded,
198    /// Entry is removed from normal retrieval.
199    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/// Relationship produced by the governance-analysis pass.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum MemoryGovernanceRelationship {
233    /// Two entries appear to represent the same durable knowledge.
234    Duplicate,
235    /// Two entries carry contradictory durable knowledge.
236    ConflictsWith,
237    /// This entry appears weaker than a newer/better replacement.
238    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/// Persisted governance suggestion for operator review.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct MemoryGovernanceSuggestion {
267    /// Related entry id (workspace-relative path when available).
268    pub entry_id: String,
269    /// Relationship between the current entry and the related entry.
270    pub relationship: MemoryGovernanceRelationship,
271    /// Confidence score for the suggestion.
272    pub confidence: f32,
273    /// Human-readable rationale shown in operator tooling.
274    pub rationale: String,
275}
276
277/// Summary of a governance refresh pass.
278#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub struct MemoryGovernanceRefreshReport {
280    /// Number of entries examined.
281    pub entries_scanned: usize,
282    /// Number of entries that changed on disk.
283    pub updated_entries: usize,
284    /// Number of duplicate suggestions persisted.
285    pub duplicate_suggestions: usize,
286    /// Number of conflict suggestions persisted.
287    pub conflict_suggestions: usize,
288    /// Number of supersession suggestions persisted.
289    pub superseded_suggestions: usize,
290}
291
292/// Filter options for targeted memory-bank retrieval.
293#[derive(Debug, Clone)]
294pub struct MemoryBankQuery {
295    /// Free-text query used for summary/content/tag matching.
296    pub text: Option<String>,
297    /// Maximum number of results to return.
298    pub limit: usize,
299    /// Optional memory-kind restrictions.
300    pub kinds: Vec<MemoryKind>,
301    /// Optional memory-type restrictions.
302    pub memory_types: Vec<MemoryType>,
303    /// Optional scope restrictions.
304    pub scopes: Vec<MemoryScope>,
305    /// Optional session filter.
306    pub session_id: Option<String>,
307    /// Optional task filter.
308    pub task_id: Option<String>,
309    /// Optional directive filter.
310    pub directive_id: Option<String>,
311    /// Optional agent filter.
312    pub agent_id: Option<String>,
313    /// Optional category filter.
314    pub category: Option<String>,
315    /// Optional tag filter (any match).
316    pub tags: Vec<String>,
317    /// Optional minimum confidence threshold.
318    pub min_confidence: Option<f32>,
319    /// Whether archived entries should be returned.
320    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    /// Create a query from free text.
345    pub fn text(text: impl Into<String>) -> Self {
346        Self {
347            text: Some(text.into()),
348            ..Self::default()
349        }
350    }
351
352    /// Set result limit.
353    pub fn with_limit(mut self, limit: usize) -> Self {
354        self.limit = limit;
355        self
356    }
357
358    /// Restrict to a specific memory scope.
359    pub fn with_scope(mut self, scope: MemoryScope) -> Self {
360        self.scopes.push(scope);
361        self
362    }
363
364    /// Restrict to a specific memory type.
365    pub fn with_memory_type(mut self, memory_type: MemoryType) -> Self {
366        self.memory_types.push(memory_type);
367        self
368    }
369
370    /// Restrict to a specific session.
371    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    /// Restrict to a specific task.
377    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    /// Restrict to a specific directive.
383    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    /// Restrict to a specific agent.
389    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    /// Restrict to a specific category.
395    pub fn with_category(mut self, category: impl Into<String>) -> Self {
396        self.category = Some(category.into());
397        self
398    }
399
400    /// Require at least one of the supplied tags.
401    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
402        self.tags = tags;
403        self
404    }
405
406    /// Require a minimum confidence value.
407    pub fn with_min_confidence(mut self, confidence: f32) -> Self {
408        self.min_confidence = Some(confidence);
409        self
410    }
411
412    /// Include archived entries in results.
413    pub fn include_archived(mut self) -> Self {
414        self.include_archived = true;
415        self
416    }
417}
418
419/// Ranked result from targeted memory-bank retrieval.
420#[derive(Debug, Clone)]
421pub struct MemorySearchResult {
422    /// Matching memory entry.
423    pub entry: MemoryBankEntry,
424    /// Ranking score (higher is better).
425    pub score: f32,
426    /// Fields that contributed to the match.
427    pub matched_fields: Vec<String>,
428}
429
430/// Errors that can occur during memory bank operations
431///
432/// # Examples
433///
434/// ```rust,ignore
435/// use gestura_core::memory_bank::{MemoryBankError, load_from_memory_bank};
436/// use std::path::Path;
437///
438/// # async fn example() -> Result<(), MemoryBankError> {
439/// match load_from_memory_bank(Path::new("invalid.md")).await {
440///     Err(MemoryBankError::Io(e)) => println!("File not found: {}", e),
441///     Err(MemoryBankError::Parse(msg)) => println!("Invalid format: {}", msg),
442///     Err(MemoryBankError::DirectoryNotFound(path)) => println!("Directory not found: {}", path.display()),
443///     Err(MemoryBankError::InvalidEntryPath { file_path, memory_dir }) => {
444///         println!(
445///             "Invalid entry path: {} (expected under {})",
446///             file_path.display(),
447///             memory_dir.display()
448///         )
449///     }
450///     Ok(entry) => println!("Loaded: {}", entry.summary),
451/// }
452/// # Ok(())
453/// # }
454/// ```
455#[derive(Debug, Error)]
456pub enum MemoryBankError {
457    /// I/O error during file operations (e.g., file not found, permission denied)
458    #[error("I/O error: {0}")]
459    Io(#[from] std::io::Error),
460    /// Error parsing markdown content (e.g., missing required fields, invalid format)
461    #[error("Parse error: {0}")]
462    Parse(String),
463    /// Memory bank directory not found at the expected location
464    #[error("Memory bank directory not found: {0}")]
465    DirectoryNotFound(PathBuf),
466
467    /// An entry path was provided that is not within the workspace's memory bank directory
468    #[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/// A single entry in the memory bank representing a saved conversation context
476///
477/// Each entry contains metadata (timestamp, session ID, summary) and the full
478/// conversation content. Entries are stored as markdown files in `.gestura/memory/`
479/// and can be searched and retrieved across sessions.
480///
481/// # Examples
482///
483/// ```rust,ignore
484/// use gestura_core::memory_bank::MemoryBankEntry;
485///
486/// let entry = MemoryBankEntry::new(
487///     "session-123".to_string(),
488///     "Implemented user authentication".to_string(),
489///     "User: How do I add auth?\nAssistant: Here's how...".to_string(),
490/// );
491///
492/// let markdown = entry.to_markdown();
493/// let filename = entry.generate_filename(); // e.g., "memory_20260121_143022_session-1.md"
494/// ```
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct MemoryBankEntry {
497    /// Timestamp when this entry was created (UTC)
498    pub timestamp: DateTime<Utc>,
499    /// Retention domain for the memory.
500    #[serde(default)]
501    pub memory_kind: MemoryKind,
502    /// Typed classification of the memory entry.
503    #[serde(default)]
504    pub memory_type: MemoryType,
505    /// Retrieval scope for the memory entry.
506    #[serde(default)]
507    pub scope: MemoryScope,
508    /// Session ID that created this entry (used for grouping related conversations)
509    pub session_id: String,
510    /// Optional category for grouping/filtering entries (e.g., "project", "personal", "research")
511    pub category: Option<String>,
512    /// Optional task identifier associated with the memory.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub task_id: Option<String>,
515    /// Optional stable reflection identifier linked to the memory.
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub reflection_id: Option<String>,
518    /// Optional normalized strategy key for reflection-derived memories.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub strategy_key: Option<String>,
521    /// Reflection retrieval state derived from downstream outcomes.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub reflection_state: Option<ReflectionMemoryState>,
524    /// Count of positive downstream outcomes associated with the reflection.
525    #[serde(default, skip_serializing_if = "is_zero_u16")]
526    pub success_count: u16,
527    /// Count of negative downstream outcomes associated with the reflection.
528    #[serde(default, skip_serializing_if = "is_zero_u16")]
529    pub failure_count: u16,
530    /// Optional higher-level directive identifier associated with the memory.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub directive_id: Option<String>,
533    /// Optional agent identifier that produced or owns the memory.
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub agent_id: Option<String>,
536    /// General governance state for the durable memory entry.
537    #[serde(default, skip_serializing_if = "is_default_governance_state")]
538    pub governance_state: MemoryGovernanceState,
539    /// Optional operator note describing curation intent.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub governance_note: Option<String>,
542    /// Persisted governance suggestions produced by automated analysis.
543    #[serde(default, skip_serializing_if = "Vec::is_empty")]
544    pub governance_suggestions: Vec<MemoryGovernanceSuggestion>,
545    /// Tags used for targeted retrieval.
546    #[serde(default, skip_serializing_if = "Vec::is_empty")]
547    pub tags: Vec<String>,
548    /// Optional originating short-term session when this record was promoted.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub promoted_from_session_id: Option<String>,
551    /// Optional explanation of why the record was promoted.
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub promotion_reason: Option<String>,
554    /// Optional human-readable outcome summary linked to this memory's provenance.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub outcome_summary: Option<String>,
557    /// Stable outcome labels linked to this memory's provenance.
558    #[serde(default, skip_serializing_if = "Vec::is_empty")]
559    pub outcome_labels: Vec<String>,
560    /// Confidence score for retrieval/ranking.
561    #[serde(default = "default_memory_confidence")]
562    pub confidence: f32,
563    /// Brief summary of the conversation (used for search and display)
564    pub summary: String,
565    /// Full conversation context in markdown format
566    pub content: String,
567    /// File path where this entry is stored (not serialized, populated on load)
568    #[serde(skip)]
569    pub file_path: Option<PathBuf>,
570}
571
572impl PartialEq for MemoryBankEntry {
573    fn eq(&self, other: &Self) -> bool {
574        // `file_path` is an on-disk detail populated on load and is intentionally
575        // excluded from equality so entries compare based on their semantic content.
576        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    /// Create a new memory bank entry with the current timestamp
608    ///
609    /// # Arguments
610    ///
611    /// * `session_id` - Unique identifier for the session that created this entry
612    /// * `summary` - Brief description of the conversation (used for search)
613    /// * `content` - Full conversation context in markdown format
614    ///
615    /// # Examples
616    ///
617    /// ```rust,ignore
618    /// use gestura_core::memory_bank::MemoryBankEntry;
619    ///
620    /// let entry = MemoryBankEntry::new(
621    ///     "session-abc123".to_string(),
622    ///     "Fixed authentication bug".to_string(),
623    ///     "User: Auth is broken\nAssistant: Here's the fix...".to_string(),
624    /// );
625    /// ```
626    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    /// Override the memory type for this entry.
658    pub fn with_memory_type(mut self, memory_type: MemoryType) -> Self {
659        self.memory_type = memory_type;
660        self
661    }
662
663    /// Override the memory scope for this entry.
664    pub fn with_scope(mut self, scope: MemoryScope) -> Self {
665        self.scope = scope;
666        self
667    }
668
669    /// Attach a category to the entry.
670    pub fn with_category(mut self, category: impl Into<String>) -> Self {
671        self.category = Some(category.into());
672        self
673    }
674
675    /// Attach provenance identifiers to the entry.
676    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    /// Attach a stable reflection identifier to the entry.
689    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    /// Attach reflection-learning metadata used for ranking and governance.
695    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    /// Attach retrieval tags to the entry.
710    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
711        self.tags = tags;
712        self
713    }
714
715    /// Set the general governance state.
716    pub fn with_governance_state(mut self, state: MemoryGovernanceState) -> Self {
717        self.governance_state = state;
718        self
719    }
720
721    /// Attach an operator note describing memory curation context.
722    pub fn with_governance_note(mut self, note: impl Into<String>) -> Self {
723        self.governance_note = Some(note.into());
724        self
725    }
726
727    /// Replace automated governance suggestions.
728    pub fn with_governance_suggestions(
729        mut self,
730        suggestions: Vec<MemoryGovernanceSuggestion>,
731    ) -> Self {
732        self.governance_suggestions = suggestions;
733        self
734    }
735
736    /// Mark the entry as promoted from short-term memory.
737    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    /// Attach durable outcome provenance to the entry.
748    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    /// Override the retrieval confidence for this entry.
759    pub fn with_confidence(mut self, confidence: f32) -> Self {
760        self.confidence = confidence.clamp(0.0, 1.0);
761        self
762    }
763
764    /// Returns true when the memory has been archived from retrieval.
765    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    /// Returns true when the reflection can safely be injected into prompts.
775    pub fn is_prompt_eligible_reflection(&self) -> bool {
776        !matches!(
777            self.reflection_state,
778            Some(ReflectionMemoryState::NeedsReview | ReflectionMemoryState::Archived)
779        )
780    }
781
782    /// Convert entry to markdown format for file storage
783    ///
784    /// The markdown format includes metadata headers and the full context:
785    /// ```markdown
786    /// # Memory Bank Entry
787    ///
788    /// **Timestamp**: 2026-01-21 14:30:22 UTC
789    /// **Session ID**: session-abc123
790    /// **Category**: engineering   # optional
791    /// **Summary**: Fixed authentication bug
792    ///
793    /// ## Context
794    ///
795    /// [conversation content here]
796    /// ```
797    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    /// Parse entry from markdown format
952    ///
953    /// # Arguments
954    ///
955    /// * `markdown` - Markdown content to parse
956    /// * `file_path` - Optional path to the source file (stored in the entry)
957    ///
958    /// # Returns
959    ///
960    /// Parsed memory bank entry
961    ///
962    /// # Errors
963    ///
964    /// Returns `MemoryBankError::Parse` if required fields are missing or invalid
965    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                // Parse timestamp - remove " UTC" suffix and parse as naive datetime, then assume UTC
1001                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); // Skip the header and blank line
1146                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    /// Generate a unique filename for this entry
1198    ///
1199    /// Format: `memory_YYYYMMDD_HHMMSS_<session-prefix>.md`
1200    ///
1201    /// # Examples
1202    ///
1203    /// ```rust,ignore
1204    /// use gestura_core::memory_bank::MemoryBankEntry;
1205    ///
1206    /// let entry = MemoryBankEntry::new(
1207    ///     "session-abc123".to_string(),
1208    ///     "Summary".to_string(),
1209    ///     "Content".to_string(),
1210    /// );
1211    ///
1212    /// let filename = entry.generate_filename();
1213    /// // e.g., "memory_20260121_143022_session-a.md"
1214    /// assert!(filename.starts_with("memory_"));
1215    /// assert!(filename.ends_with(".md"));
1216    /// ```
1217    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    /// Return true when the entry satisfies the supplied filter.
1226    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    /// Rank the entry against a targeted query.
1299    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    /// Render a compact section suitable for prompt context.
1508    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
1569/// Get the memory bank directory path for a workspace
1570///
1571/// Returns the path to `.gestura/memory/` within the workspace directory.
1572/// This directory is created automatically when saving entries.
1573///
1574/// # Arguments
1575///
1576/// * `workspace_dir` - Root directory of the workspace
1577///
1578/// # Examples
1579///
1580/// ```rust,ignore
1581/// use gestura_core::memory_bank::get_memory_bank_dir;
1582/// use std::path::Path;
1583///
1584/// let workspace = Path::new("/home/user/project");
1585/// let memory_dir = get_memory_bank_dir(workspace);
1586/// assert_eq!(memory_dir, Path::new("/home/user/project/.gestura/memory"));
1587/// ```
1588pub fn get_memory_bank_dir(workspace_dir: &Path) -> PathBuf {
1589    workspace_dir.join(".gestura").join("memory")
1590}
1591
1592/// Ensure the memory bank directory exists, creating it if necessary
1593///
1594/// # Arguments
1595///
1596/// * `workspace_dir` - Root directory of the workspace
1597///
1598/// # Returns
1599///
1600/// Path to the memory bank directory
1601///
1602/// # Errors
1603///
1604/// Returns `MemoryBankError::Io` if directory creation fails
1605pub 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
1611/// Save a memory bank entry to disk as a markdown file
1612///
1613/// Creates the `.gestura/memory/` directory if it doesn't exist, then writes
1614/// the entry as a markdown file with a timestamp-based filename.
1615///
1616/// # Arguments
1617///
1618/// * `workspace_dir` - The workspace directory (memory will be saved to `.gestura/memory/`)
1619/// * `entry` - The memory bank entry to save
1620///
1621/// # Returns
1622///
1623/// Path to the saved file
1624///
1625/// # Errors
1626///
1627/// Returns `MemoryBankError::Io` if directory creation or file write fails
1628///
1629/// # Examples
1630///
1631/// ```rust,ignore
1632/// use gestura_core::memory_bank::{MemoryBankEntry, save_to_memory_bank};
1633/// use std::path::Path;
1634///
1635/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1636/// let entry = MemoryBankEntry::new(
1637///     "session-123".to_string(),
1638///     "Fixed bug".to_string(),
1639///     "Conversation content...".to_string(),
1640/// );
1641///
1642/// let workspace = Path::new("/home/user/project");
1643/// let file_path = save_to_memory_bank(workspace, &entry).await?;
1644/// println!("Saved to: {}", file_path.display());
1645/// # Ok(())
1646/// # }
1647/// ```
1648pub 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
1668/// Load a memory bank entry from a markdown file
1669///
1670/// # Arguments
1671///
1672/// * `file_path` - Path to the memory bank markdown file
1673///
1674/// # Returns
1675///
1676/// The loaded memory bank entry with `file_path` populated
1677///
1678/// # Errors
1679///
1680/// Returns `MemoryBankError::Io` if file read fails, or `MemoryBankError::Parse`
1681/// if the markdown format is invalid
1682///
1683/// # Examples
1684///
1685/// ```rust,ignore
1686/// use gestura_core::memory_bank::load_from_memory_bank;
1687/// use std::path::Path;
1688///
1689/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1690/// let file_path = Path::new(".gestura/memory/memory_20260121_143022_session-a.md");
1691/// let entry = load_from_memory_bank(file_path).await?;
1692/// println!("Loaded: {}", entry.summary);
1693/// # Ok(())
1694/// # }
1695/// ```
1696pub 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
1701/// List all memory bank entries in a workspace
1702///
1703/// Scans the `.gestura/memory/` directory for all markdown files and loads them.
1704/// Invalid or corrupted files are logged as warnings and skipped.
1705///
1706/// # Arguments
1707///
1708/// * `workspace_dir` - The workspace directory containing `.gestura/memory/`
1709///
1710/// # Returns
1711///
1712/// Vector of all memory bank entries, sorted by timestamp (newest first)
1713///
1714/// # Errors
1715///
1716/// Returns `MemoryBankError::Io` if directory read fails. Individual file
1717/// parse errors are logged but don't fail the entire operation.
1718///
1719/// # Examples
1720///
1721/// ```rust,ignore
1722/// use gestura_core::memory_bank::list_memory_bank;
1723/// use std::path::Path;
1724///
1725/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1726/// let workspace = Path::new("/home/user/project");
1727/// let entries = list_memory_bank(workspace).await?;
1728///
1729/// for entry in entries {
1730///     println!("{}: {}", entry.timestamp, entry.summary);
1731/// }
1732/// # Ok(())
1733/// # }
1734/// ```
1735pub 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    // Sort by timestamp, newest first
1764    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            // On some platforms (notably Windows), rename won't overwrite an existing file.
1815            // Best-effort fallback: remove destination, then rename again.
1816            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
1826/// Delete a single memory bank entry.
1827///
1828/// This will validate that the entry path is a markdown file located under
1829/// the workspace's `.gestura/memory/` directory.
1830pub 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
1839/// Update a single memory bank entry in-place.
1840///
1841/// This will validate that `file_path` is a markdown file located under the
1842/// workspace's `.gestura/memory/` directory, then rewrite the file contents.
1843pub 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
1854/// Search memory bank entries for relevant content
1855///
1856/// Performs a case-insensitive substring search across both the summary and
1857/// content fields of all memory bank entries. Results are sorted using the
1858/// targeted ranking heuristic and then truncated to the requested limit.
1859///
1860/// # Arguments
1861///
1862/// * `workspace_dir` - The workspace directory containing `.gestura/memory/`
1863/// * `query` - Search query string (case-insensitive)
1864/// * `limit` - Maximum number of results to return
1865///
1866/// # Returns
1867///
1868/// Vector of matching memory bank entries, sorted by timestamp (newest first)
1869///
1870/// # Errors
1871///
1872/// Returns `MemoryBankError::Io` if directory read fails
1873///
1874/// # Examples
1875///
1876/// ```rust,ignore
1877/// use gestura_core::memory_bank::search_memory_bank;
1878/// use std::path::Path;
1879///
1880/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1881/// let workspace = Path::new("/home/user/project");
1882/// let results = search_memory_bank(workspace, "authentication", 5).await?;
1883///
1884/// for entry in results {
1885///     println!("Found: {} - {}", entry.timestamp, entry.summary);
1886/// }
1887/// # Ok(())
1888/// # }
1889/// ```
1890pub 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
1900/// Search memory-bank entries using structured filters and ranking.
1901pub 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
1922/// Refresh persisted governance suggestions for durable memory entries.
1923pub 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
2263/// Clear all memory bank entries in a workspace
2264///
2265/// Deletes all markdown files in the `.gestura/memory/` directory. This operation
2266/// is irreversible and should be used with caution.
2267///
2268/// # Arguments
2269///
2270/// * `workspace_dir` - The workspace directory containing `.gestura/memory/`
2271///
2272/// # Returns
2273///
2274/// Number of entries deleted
2275///
2276/// # Errors
2277///
2278/// Returns `MemoryBankError::Io` if directory read or file deletion fails
2279///
2280/// # Examples
2281///
2282/// ```rust,ignore
2283/// use gestura_core::memory_bank::clear_memory_bank;
2284/// use std::path::Path;
2285///
2286/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
2287/// let workspace = Path::new("/home/user/project");
2288/// let count = clear_memory_bank(workspace).await?;
2289/// println!("Deleted {} memory bank entries", count);
2290/// # Ok(())
2291/// # }
2292/// ```
2293pub 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        // Ensure directory is created
2327        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        // Create an entry
2343        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        // Save to memory bank
2351        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        // Load from memory bank
2361        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        // Initially empty
2378        let entries = list_memory_bank(workspace_path).await.unwrap();
2379        assert_eq!(entries.len(), 0, "Memory bank should be empty initially");
2380
2381        // Save multiple entries with unique session IDs to avoid filename collisions
2382        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            // Small delay to ensure different timestamps
2390            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2391        }
2392
2393        // List should return all entries
2394        let entries = list_memory_bank(workspace_path).await.unwrap();
2395        assert_eq!(entries.len(), 3, "Memory bank should contain 3 entries");
2396
2397        // Entries should be sorted by timestamp (newest first)
2398        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        // Save entries with different content and unique session IDs
2412        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            // Small delay to ensure different timestamps
2438            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2439        }
2440
2441        // Search for "authentication"
2442        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        // Search for "user"
2453        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        // Search with limit
2459        let results = search_memory_bank(workspace_path, "user", 1).await.unwrap();
2460        assert_eq!(results.len(), 1, "Should respect limit parameter");
2461
2462        // Search for non-existent term
2463        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        // Update
2832        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
2847        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        // Ensure memory bank dir exists so the error is about path validation.
2862        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        // We don't actually need a valid loaded entry here; just an entry payload.
2875        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        // Save multiple entries with unique session IDs
2896        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            // Small delay to ensure different timestamps
2904            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2905        }
2906
2907        // Verify entries exist
2908        let entries = list_memory_bank(workspace_path).await.unwrap();
2909        assert_eq!(entries.len(), 5, "Should have 5 entries before clear");
2910
2911        // Clear memory bank
2912        let count = clear_memory_bank(workspace_path).await.unwrap();
2913        assert_eq!(count, 5, "Should clear 5 entries");
2914
2915        // Verify entries are gone
2916        let entries = list_memory_bank(workspace_path).await.unwrap();
2917        assert_eq!(entries.len(), 0, "Memory bank should be empty after clear");
2918
2919        // Clear again should return 0
2920        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        // Create an entry with specific content
2930        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        // Save to memory bank
2937        let file_path = save_to_memory_bank(workspace_path, &entry).await.unwrap();
2938
2939        // Read raw markdown file
2940        let markdown = tokio::fs::read_to_string(&file_path).await.unwrap();
2941
2942        println!("Generated markdown:\n{}", markdown);
2943
2944        // Verify markdown format
2945        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}