Skip to main content

a3s_memory/
lib.rs

1//! A3S Memory — pluggable memory storage for AI agents.
2//!
3//! Provides the `MemoryStore` trait, `MemoryItem`, `MemoryType`,
4//! configuration types, and the following backend implementations:
5//!
6//! | Backend | Feature | Search |
7//! |---------|---------|--------|
8//! | [`FileMemoryStore`] | always available | substring |
9//! | [`SqliteMemoryStore`] | `sqlite` | BM25 via FTS5 |
10//!
11//! The [`FileMemoryStore`] is the default and requires no additional dependencies.
12
13/// SQLite-backed memory store with dual-track Markdown export.
14///
15/// Requires the `sqlite` Cargo feature.
16#[cfg(feature = "sqlite")]
17pub mod sqlite;
18
19#[cfg(feature = "sqlite")]
20pub use sqlite::SqliteMemoryStore;
21
22use anyhow::Context as _;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use std::collections::{HashMap, HashSet};
26use std::sync::OnceLock;
27use tokio::sync::RwLock;
28
29const MIN_DEDUPE_FINGERPRINT_CHARS: usize = 24;
30const MIN_NEAR_DEDUPE_TERMS: usize = 5;
31const NEAR_DEDUPE_JACCARD_THRESHOLD: f32 = 0.86;
32const PRUNE_PROTECTED_ACCESS_COUNT: u32 = 3;
33
34// ============================================================================
35// Configuration
36// ============================================================================
37
38/// Configuration for relevance scoring
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct RelevanceConfig {
42    /// Exponential decay half-life in days (default: 30.0)
43    #[serde(default = "RelevanceConfig::default_decay_days")]
44    pub decay_days: f32,
45    /// Weight for importance factor (default: 0.7)
46    #[serde(default = "RelevanceConfig::default_importance_weight")]
47    pub importance_weight: f32,
48    /// Weight for recency factor (default: 0.3)
49    #[serde(default = "RelevanceConfig::default_recency_weight")]
50    pub recency_weight: f32,
51}
52
53impl RelevanceConfig {
54    fn default_decay_days() -> f32 {
55        30.0
56    }
57    fn default_importance_weight() -> f32 {
58        0.7
59    }
60    fn default_recency_weight() -> f32 {
61        0.3
62    }
63}
64
65impl Default for RelevanceConfig {
66    fn default() -> Self {
67        Self {
68            decay_days: 30.0,
69            importance_weight: 0.7,
70            recency_weight: 0.3,
71        }
72    }
73}
74
75/// Policy controlling automatic pruning of long-term memory.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct PrunePolicy {
79    /// Items older than this many days AND below `min_importance_to_keep` are deleted (default: 90).
80    #[serde(default = "PrunePolicy::default_max_age_days")]
81    pub max_age_days: u32,
82    /// Items with importance below this threshold are eligible for age-based deletion (default: 0.5).
83    /// High-importance items are never age-pruned.
84    #[serde(default = "PrunePolicy::default_min_importance_to_keep")]
85    pub min_importance_to_keep: f32,
86    /// Hard cap on total items; when exceeded, lowest-relevance items are removed.
87    /// 0 means unlimited (default: 0).
88    #[serde(default)]
89    pub max_items: usize,
90}
91
92impl PrunePolicy {
93    fn default_max_age_days() -> u32 {
94        90
95    }
96    fn default_min_importance_to_keep() -> f32 {
97        0.5
98    }
99}
100
101impl Default for PrunePolicy {
102    fn default() -> Self {
103        Self {
104            max_age_days: 90,
105            min_importance_to_keep: 0.5,
106            max_items: 0,
107        }
108    }
109}
110
111// ============================================================================
112// Memory Item
113// ============================================================================
114
115/// A single memory item
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct MemoryItem {
118    pub id: String,
119    pub content: String,
120    pub timestamp: DateTime<Utc>,
121    pub importance: f32,
122    pub tags: Vec<String>,
123    pub memory_type: MemoryType,
124    pub metadata: HashMap<String, String>,
125    pub access_count: u32,
126    pub last_accessed: Option<DateTime<Utc>>,
127    #[serde(skip)]
128    pub content_lower: String,
129}
130
131impl MemoryItem {
132    pub fn new(content: impl Into<String>) -> Self {
133        let content = content.into();
134        let content_lower = content.to_lowercase();
135        Self {
136            id: uuid::Uuid::new_v4().to_string(),
137            content,
138            timestamp: Utc::now(),
139            importance: 0.5,
140            tags: Vec::new(),
141            memory_type: MemoryType::Episodic,
142            metadata: HashMap::new(),
143            access_count: 0,
144            last_accessed: None,
145            content_lower,
146        }
147    }
148
149    pub fn with_importance(mut self, importance: f32) -> Self {
150        self.importance = importance.clamp(0.0, 1.0);
151        self
152    }
153
154    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
155        self.tags = tags;
156        self
157    }
158
159    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
160        self.tags.push(tag.into());
161        self
162    }
163
164    pub fn with_type(mut self, memory_type: MemoryType) -> Self {
165        self.memory_type = memory_type;
166        self
167    }
168
169    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
170        self.metadata.insert(key.into(), value.into());
171        self
172    }
173
174    /// Stable, punctuation-insensitive content fingerprint used by default
175    /// stores to collapse exact durable duplicates.
176    ///
177    /// Very short memories return `None` so generic fragments such as "ok" or
178    /// "done" are not accidentally merged.
179    pub fn content_fingerprint(&self) -> Option<String> {
180        memory_content_fingerprint(&self.content)
181    }
182
183    /// Merge a later duplicate observation into this canonical memory item.
184    ///
185    /// The canonical id is preserved while importance, tags, list-style
186    /// metadata, access stats, and duplicate audit metadata are consolidated.
187    pub fn merge_duplicate(self, incoming: MemoryItem) -> MemoryItem {
188        merge_duplicate_memory_item(self, incoming)
189    }
190
191    pub fn record_access(&mut self) {
192        self.access_count += 1;
193        self.last_accessed = Some(Utc::now());
194    }
195
196    /// Calculate relevance score at a given timestamp using the provided config
197    pub fn relevance_score_at(&self, now: DateTime<Utc>, config: &RelevanceConfig) -> f32 {
198        let age_days = (now - self.timestamp).num_seconds() as f32 / 86400.0;
199        let decay = (-age_days / config.decay_days).exp();
200        self.importance * config.importance_weight + decay * config.recency_weight
201    }
202
203    /// Calculate relevance score with default config
204    pub fn relevance_score(&self) -> f32 {
205        self.relevance_score_at(Utc::now(), &RelevanceConfig::default())
206    }
207}
208
209fn normalize_item_for_store(mut item: MemoryItem) -> MemoryItem {
210    item.content_lower = item.content.to_lowercase();
211    item
212}
213
214fn memory_content_fingerprint(content: &str) -> Option<String> {
215    let mut tokens = Vec::new();
216    let mut current = String::new();
217    for ch in content.chars().flat_map(char::to_lowercase) {
218        if ch.is_alphanumeric() {
219            current.push(ch);
220        } else if !current.is_empty() {
221            tokens.push(std::mem::take(&mut current));
222        }
223    }
224    if !current.is_empty() {
225        tokens.push(current);
226    }
227
228    let fingerprint = tokens.join(" ");
229    if fingerprint.chars().count() < MIN_DEDUPE_FINGERPRINT_CHARS {
230        None
231    } else {
232        Some(fingerprint)
233    }
234}
235
236fn memories_are_store_duplicates(existing: &MemoryItem, incoming: &MemoryItem) -> bool {
237    if existing.id == incoming.id {
238        return true;
239    }
240    if existing.content_fingerprint().is_some()
241        && existing.content_fingerprint() == incoming.content_fingerprint()
242    {
243        return true;
244    }
245    memory_items_are_near_duplicates(existing, incoming)
246}
247
248fn memory_index_entry_is_duplicate(entry: &IndexEntry, incoming: &MemoryItem) -> bool {
249    if entry.id == incoming.id {
250        return true;
251    }
252    if incoming.content_fingerprint().is_some()
253        && memory_content_fingerprint(&entry.content_lower) == incoming.content_fingerprint()
254    {
255        return true;
256    }
257    memory_contents_are_near_duplicates(&entry.content_lower, entry.memory_type, incoming)
258}
259
260fn memory_items_are_near_duplicates(existing: &MemoryItem, incoming: &MemoryItem) -> bool {
261    memory_contents_are_near_duplicates(&existing.content, existing.memory_type, incoming)
262}
263
264fn memory_contents_are_near_duplicates(
265    existing_content: &str,
266    existing_type: MemoryType,
267    incoming: &MemoryItem,
268) -> bool {
269    if existing_type != incoming.memory_type {
270        return false;
271    }
272    if has_conflicting_dedupe_polarity(existing_content, &incoming.content) {
273        return false;
274    }
275
276    let existing_terms = dedupe_terms(existing_content);
277    let incoming_terms = dedupe_terms(&incoming.content);
278    if existing_terms.len() < MIN_NEAR_DEDUPE_TERMS || incoming_terms.len() < MIN_NEAR_DEDUPE_TERMS
279    {
280        return false;
281    }
282
283    let overlap = existing_terms.intersection(&incoming_terms).count();
284    let union = existing_terms.len() + incoming_terms.len() - overlap;
285    union > 0 && overlap as f32 / union as f32 >= NEAR_DEDUPE_JACCARD_THRESHOLD
286}
287
288fn dedupe_terms(content: &str) -> HashSet<String> {
289    content
290        .to_ascii_lowercase()
291        .split(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/')))
292        .map(str::trim)
293        .filter(|term| term.chars().count() >= 3)
294        .filter(|term| !is_dedupe_stopword(term))
295        .map(ToOwned::to_owned)
296        .collect()
297}
298
299fn has_conflicting_dedupe_polarity(left: &str, right: &str) -> bool {
300    has_negation_term(left) != has_negation_term(right)
301}
302
303fn has_negation_term(content: &str) -> bool {
304    content
305        .to_ascii_lowercase()
306        .split(|ch: char| !ch.is_alphanumeric())
307        .any(|term| {
308            matches!(
309                term,
310                "not" | "never" | "no" | "avoid" | "without" | "disable"
311            )
312        })
313}
314
315fn is_dedupe_stopword(term: &str) -> bool {
316    matches!(
317        term,
318        "the"
319            | "and"
320            | "for"
321            | "with"
322            | "after"
323            | "before"
324            | "from"
325            | "that"
326            | "this"
327            | "when"
328            | "then"
329            | "than"
330            | "into"
331            | "must"
332            | "should"
333            | "would"
334            | "could"
335            | "about"
336    )
337}
338
339fn merge_duplicate_memory_item(existing: MemoryItem, incoming: MemoryItem) -> MemoryItem {
340    let incoming = normalize_item_for_store(incoming);
341    let mut merged = existing.clone();
342
343    if should_replace_duplicate_content(&existing, &incoming) {
344        merged.content = incoming.content.clone();
345        merged.content_lower = incoming.content_lower.clone();
346    }
347    merged.importance = existing.importance.max(incoming.importance);
348    merged.timestamp = existing.timestamp.max(incoming.timestamp);
349    merged.memory_type = stronger_memory_type(existing.memory_type, incoming.memory_type);
350    merged.access_count = existing.access_count.max(incoming.access_count);
351    merged.last_accessed = max_optional_datetime(existing.last_accessed, incoming.last_accessed);
352
353    merge_tags(&mut merged.tags, &incoming.tags);
354    merge_metadata(&mut merged.metadata, &incoming.metadata);
355    record_duplicate_metadata(&mut merged.metadata, &incoming.id);
356
357    normalize_item_for_store(merged)
358}
359
360fn should_replace_duplicate_content(existing: &MemoryItem, incoming: &MemoryItem) -> bool {
361    incoming.importance > existing.importance
362        || (incoming.importance == existing.importance
363            && incoming.content.chars().count() > existing.content.chars().count())
364}
365
366fn stronger_memory_type(existing: MemoryType, incoming: MemoryType) -> MemoryType {
367    if memory_type_strength(incoming) > memory_type_strength(existing) {
368        incoming
369    } else {
370        existing
371    }
372}
373
374fn memory_type_strength(memory_type: MemoryType) -> u8 {
375    match memory_type {
376        MemoryType::Procedural | MemoryType::Semantic => 3,
377        MemoryType::Working => 2,
378        MemoryType::Episodic => 1,
379    }
380}
381
382fn max_optional_datetime(
383    left: Option<DateTime<Utc>>,
384    right: Option<DateTime<Utc>>,
385) -> Option<DateTime<Utc>> {
386    match (left, right) {
387        (Some(left), Some(right)) => Some(left.max(right)),
388        (Some(left), None) => Some(left),
389        (None, Some(right)) => Some(right),
390        (None, None) => None,
391    }
392}
393
394fn merge_tags(existing: &mut Vec<String>, incoming: &[String]) {
395    for tag in incoming {
396        if !existing.contains(tag) {
397            existing.push(tag.clone());
398        }
399    }
400}
401
402fn merge_metadata(existing: &mut HashMap<String, String>, incoming: &HashMap<String, String>) {
403    for (key, value) in incoming {
404        if value.trim().is_empty() {
405            continue;
406        }
407        match existing.get_mut(key) {
408            Some(current) if current == value => {}
409            Some(current) if is_list_metadata_key(key) => {
410                *current = merge_metadata_list(current, value);
411            }
412            Some(_) => {}
413            None => {
414                existing.insert(key.clone(), value.clone());
415            }
416        }
417    }
418}
419
420fn is_list_metadata_key(key: &str) -> bool {
421    matches!(
422        key,
423        "supersedes" | "conflicts_with" | "tools" | "aliases" | "entity_aliases"
424    )
425}
426
427fn merge_metadata_list(existing: &str, incoming: &str) -> String {
428    let mut values = Vec::new();
429    for raw in existing.split(',').chain(incoming.split(',')) {
430        let value = raw.trim();
431        if !value.is_empty() && !values.iter().any(|seen| seen == value) {
432            values.push(value.to_string());
433        }
434    }
435    values.join(",")
436}
437
438fn record_duplicate_metadata(metadata: &mut HashMap<String, String>, incoming_id: &str) {
439    let count = metadata
440        .get("duplicate_count")
441        .and_then(|value| value.parse::<u32>().ok())
442        .unwrap_or(0)
443        + 1;
444    metadata.insert("duplicate_count".to_string(), count.to_string());
445    metadata.insert("last_duplicate_at".to_string(), Utc::now().to_rfc3339());
446    if !incoming_id.trim().is_empty() {
447        let duplicate_ids = metadata
448            .get("duplicate_ids")
449            .map(|existing| merge_metadata_list(existing, incoming_id))
450            .unwrap_or_else(|| incoming_id.to_string());
451        metadata.insert("duplicate_ids".to_string(), duplicate_ids);
452    }
453}
454
455fn memory_is_prune_protected(item: &MemoryItem) -> bool {
456    item.access_count >= PRUNE_PROTECTED_ACCESS_COUNT
457        || item.tags.iter().any(|tag| {
458            matches!(
459                tag.as_str(),
460                "keep" | "pinned" | "protected" | "consolidated" | "conflict"
461            )
462        })
463        || metadata_truthy(&item.metadata, "keep")
464        || metadata_truthy(&item.metadata, "pinned")
465        || metadata_truthy(&item.metadata, "protected")
466        || metadata_nonempty(&item.metadata, "supersedes")
467        || metadata_nonempty(&item.metadata, "conflicts_with")
468}
469
470fn metadata_truthy(metadata: &HashMap<String, String>, key: &str) -> bool {
471    metadata
472        .get(key)
473        .map(|value| {
474            matches!(
475                value.trim().to_ascii_lowercase().as_str(),
476                "1" | "true" | "yes" | "keep" | "pinned" | "protected"
477            )
478        })
479        .unwrap_or(false)
480}
481
482fn metadata_nonempty(metadata: &HashMap<String, String>, key: &str) -> bool {
483    metadata
484        .get(key)
485        .is_some_and(|value| !value.trim().is_empty())
486}
487
488/// Type of memory
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(rename_all = "snake_case")]
491pub enum MemoryType {
492    Episodic,
493    Semantic,
494    Procedural,
495    Working,
496}
497
498// ============================================================================
499// Memory Store Trait
500// ============================================================================
501
502#[async_trait::async_trait]
503pub trait MemoryStore: Send + Sync {
504    async fn store(&self, item: MemoryItem) -> anyhow::Result<()>;
505    /// Store a memory and return the canonical item that now represents it.
506    ///
507    /// Default stores may merge durable duplicate content into an existing item
508    /// and return that existing item with updated metadata. Custom backends that
509    /// do not implement deduplication can rely on this default wrapper.
510    async fn store_and_return(&self, item: MemoryItem) -> anyhow::Result<MemoryItem> {
511        self.store(item.clone()).await?;
512        Ok(item)
513    }
514    async fn retrieve(&self, id: &str) -> anyhow::Result<Option<MemoryItem>>;
515    async fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryItem>>;
516    async fn search_by_tags(
517        &self,
518        tags: &[String],
519        limit: usize,
520    ) -> anyhow::Result<Vec<MemoryItem>>;
521    async fn get_recent(&self, limit: usize) -> anyhow::Result<Vec<MemoryItem>>;
522    async fn get_important(&self, threshold: f32, limit: usize) -> anyhow::Result<Vec<MemoryItem>>;
523    async fn delete(&self, id: &str) -> anyhow::Result<()>;
524    async fn clear(&self) -> anyhow::Result<()>;
525    async fn count(&self) -> anyhow::Result<usize>;
526
527    /// Remove stale or excess items according to `policy`.
528    ///
529    /// Returns the number of items deleted. The default implementation is a
530    /// no-op (returns 0) for backwards compatibility.
531    async fn prune(&self, policy: &PrunePolicy) -> anyhow::Result<usize> {
532        let _ = policy;
533        Ok(0)
534    }
535}
536
537// ============================================================================
538// Shared helpers
539// ============================================================================
540
541/// Score an index entry for sorting (avoids loading full MemoryItem from disk)
542fn index_score(entry: &IndexEntry, now: DateTime<Utc>, config: &RelevanceConfig) -> f32 {
543    let age_days = (now - entry.timestamp).num_seconds() as f32 / 86400.0;
544    let decay = (-age_days / config.decay_days).exp();
545    entry.importance * config.importance_weight + decay * config.recency_weight
546}
547
548fn sort_by_relevance(items: &mut [MemoryItem]) {
549    let now = Utc::now();
550    let config = RelevanceConfig::default();
551    items.sort_by(|a, b| {
552        b.relevance_score_at(now, &config)
553            .partial_cmp(&a.relevance_score_at(now, &config))
554            .unwrap_or(std::cmp::Ordering::Equal)
555    });
556}
557
558fn memory_type_to_query_key(memory_type: MemoryType) -> &'static str {
559    match memory_type {
560        MemoryType::Episodic => "episodic",
561        MemoryType::Semantic => "semantic",
562        MemoryType::Procedural => "procedural",
563        MemoryType::Working => "working",
564    }
565}
566
567fn query_terms(query: &str) -> Vec<String> {
568    let mut terms: Vec<String> = query
569        .to_lowercase()
570        .split(|ch: char| {
571            !(ch.is_alphanumeric() || matches!(ch, '/' | '\\' | '_' | '-' | '.' | ':' | '@'))
572        })
573        .map(str::trim)
574        .filter(|term| term.chars().count() >= 2)
575        .map(ToOwned::to_owned)
576        .collect();
577    terms.sort();
578    terms.dedup();
579    terms
580}
581
582fn lexical_match_score(
583    content_lower: &str,
584    tags: &[String],
585    memory_type: MemoryType,
586    query_lower: &str,
587    terms: &[String],
588) -> Option<f32> {
589    if query_lower.trim().is_empty() {
590        return Some(0.0);
591    }
592
593    let mut score = 0.0;
594    let mut matched_terms = 0usize;
595    if !query_lower.is_empty() && content_lower.contains(query_lower) {
596        score += 1.25;
597    }
598
599    let memory_type = memory_type_to_query_key(memory_type);
600    for term in terms {
601        let mut matched = false;
602        if content_lower.contains(term) {
603            score += 0.35;
604            matched = true;
605        }
606        if tags.iter().any(|tag| tag.to_lowercase().contains(term)) {
607            score += 0.55;
608            matched = true;
609        }
610        if memory_type.contains(term) {
611            score += 0.20;
612            matched = true;
613        }
614        if matched {
615            matched_terms += 1;
616        }
617    }
618
619    if score <= 0.0 {
620        return None;
621    }
622
623    if !terms.is_empty() {
624        score += matched_terms as f32 / terms.len() as f32;
625    }
626    Some(score)
627}
628
629fn index_search_score(
630    entry: &IndexEntry,
631    now: DateTime<Utc>,
632    config: &RelevanceConfig,
633    query_lower: &str,
634    terms: &[String],
635) -> Option<f32> {
636    let lexical = lexical_match_score(
637        &entry.content_lower,
638        &entry.tags,
639        entry.memory_type,
640        query_lower,
641        terms,
642    )?;
643    Some(index_score(entry, now, config) + lexical)
644}
645
646// ============================================================================
647// In-Memory Store
648// ============================================================================
649
650/// In-memory `MemoryStore` implementation.
651///
652/// Useful for testing and ephemeral (non-persistent) use cases.
653pub struct InMemoryStore {
654    items: RwLock<Vec<MemoryItem>>,
655}
656
657impl Default for InMemoryStore {
658    fn default() -> Self {
659        Self::new()
660    }
661}
662
663impl InMemoryStore {
664    pub fn new() -> Self {
665        Self {
666            items: RwLock::new(Vec::new()),
667        }
668    }
669}
670
671#[async_trait::async_trait]
672impl MemoryStore for InMemoryStore {
673    async fn store(&self, item: MemoryItem) -> anyhow::Result<()> {
674        self.store_and_return(item).await.map(|_| ())
675    }
676
677    async fn store_and_return(&self, item: MemoryItem) -> anyhow::Result<MemoryItem> {
678        let item = normalize_item_for_store(item);
679        let mut items = self.items.write().await;
680        if let Some(pos) = items.iter().position(|i| i.id == item.id) {
681            items[pos] = item.clone();
682            return Ok(item);
683        }
684
685        if let Some(pos) = items
686            .iter()
687            .position(|existing| memories_are_store_duplicates(existing, &item))
688        {
689            let merged = merge_duplicate_memory_item(items[pos].clone(), item);
690            items[pos] = merged.clone();
691            return Ok(merged);
692        }
693
694        items.push(item.clone());
695        Ok(item)
696    }
697
698    async fn retrieve(&self, id: &str) -> anyhow::Result<Option<MemoryItem>> {
699        let mut items = self.items.write().await;
700        let Some(item) = items.iter_mut().find(|i| i.id == id) else {
701            return Ok(None);
702        };
703        item.record_access();
704        Ok(Some(item.clone()))
705    }
706
707    async fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
708        let query_lower = query.to_lowercase();
709        let config = RelevanceConfig::default();
710        let now = Utc::now();
711        let terms = query_terms(&query_lower);
712        let mut items = self.items.write().await;
713        let mut scored: Vec<(usize, f32)> = items
714            .iter()
715            .enumerate()
716            .filter_map(|(idx, item)| {
717                let lexical = lexical_match_score(
718                    &item.content_lower,
719                    &item.tags,
720                    item.memory_type,
721                    &query_lower,
722                    &terms,
723                )?;
724                Some((idx, item.relevance_score_at(now, &config) + lexical))
725            })
726            .collect();
727        scored.sort_by(|a, b| {
728            b.1.partial_cmp(&a.1)
729                .unwrap_or(std::cmp::Ordering::Equal)
730                .then_with(|| items[a.0].timestamp.cmp(&items[b.0].timestamp))
731        });
732        let ids: Vec<usize> = scored.into_iter().take(limit).map(|(idx, _)| idx).collect();
733        let mut matches = Vec::with_capacity(ids.len());
734        for idx in ids {
735            items[idx].record_access();
736            matches.push(items[idx].clone());
737        }
738        Ok(matches)
739    }
740
741    async fn search_by_tags(
742        &self,
743        tags: &[String],
744        limit: usize,
745    ) -> anyhow::Result<Vec<MemoryItem>> {
746        let config = RelevanceConfig::default();
747        let now = Utc::now();
748        let mut items = self.items.write().await;
749        let mut scored: Vec<(usize, f32)> = items
750            .iter()
751            .enumerate()
752            .filter(|(_, item)| tags.iter().any(|t| item.tags.contains(t)))
753            .map(|(idx, item)| (idx, item.relevance_score_at(now, &config)))
754            .collect();
755        scored.sort_by(|a, b| {
756            b.1.partial_cmp(&a.1)
757                .unwrap_or(std::cmp::Ordering::Equal)
758                .then_with(|| items[a.0].timestamp.cmp(&items[b.0].timestamp))
759        });
760        let ids: Vec<usize> = scored.into_iter().take(limit).map(|(idx, _)| idx).collect();
761        let mut matches = Vec::with_capacity(ids.len());
762        for idx in ids {
763            items[idx].record_access();
764            matches.push(items[idx].clone());
765        }
766        Ok(matches)
767    }
768
769    async fn get_recent(&self, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
770        let items = self.items.read().await;
771        let mut sorted: Vec<MemoryItem> = items.iter().cloned().collect();
772        sorted.sort_by_key(|item| std::cmp::Reverse(item.timestamp));
773        sorted.truncate(limit);
774        Ok(sorted)
775    }
776
777    async fn get_important(&self, threshold: f32, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
778        let items = self.items.read().await;
779        let mut matches: Vec<MemoryItem> = items
780            .iter()
781            .filter(|i| i.importance >= threshold)
782            .cloned()
783            .collect();
784        matches.sort_by(|a, b| {
785            b.importance
786                .partial_cmp(&a.importance)
787                .unwrap_or(std::cmp::Ordering::Equal)
788        });
789        matches.truncate(limit);
790        Ok(matches)
791    }
792
793    async fn delete(&self, id: &str) -> anyhow::Result<()> {
794        self.items.write().await.retain(|i| i.id != id);
795        Ok(())
796    }
797
798    async fn clear(&self) -> anyhow::Result<()> {
799        self.items.write().await.clear();
800        Ok(())
801    }
802
803    async fn count(&self) -> anyhow::Result<usize> {
804        Ok(self.items.read().await.len())
805    }
806
807    async fn prune(&self, policy: &PrunePolicy) -> anyhow::Result<usize> {
808        let now = Utc::now();
809        let cutoff = now - chrono::Duration::days(policy.max_age_days as i64);
810        let min_importance = policy.min_importance_to_keep;
811
812        let mut items = self.items.write().await;
813        let before = items.len();
814
815        // Phase 1: remove items that are old and below the importance threshold,
816        // unless they have explicit curation/provenance protection.
817        items.retain(|item| {
818            memory_is_prune_protected(item)
819                || item.importance >= min_importance
820                || item.timestamp >= cutoff
821        });
822
823        // Phase 2: if still over the cap, keep protected memories first and then
824        // the highest-relevance unprotected items.
825        if policy.max_items > 0 && items.len() > policy.max_items {
826            let config = RelevanceConfig::default();
827            let protected_count = items
828                .iter()
829                .filter(|item| memory_is_prune_protected(item))
830                .count();
831            let unprotected_to_keep = policy.max_items.saturating_sub(protected_count);
832            let mut unprotected_seen = 0usize;
833            items.sort_by(|a, b| {
834                memory_is_prune_protected(b)
835                    .cmp(&memory_is_prune_protected(a))
836                    .then_with(|| {
837                        b.relevance_score_at(now, &config)
838                            .partial_cmp(&a.relevance_score_at(now, &config))
839                            .unwrap_or(std::cmp::Ordering::Equal)
840                    })
841            });
842            items.retain(|item| {
843                if memory_is_prune_protected(item) {
844                    true
845                } else if unprotected_seen < unprotected_to_keep {
846                    unprotected_seen += 1;
847                    true
848                } else {
849                    false
850                }
851            });
852        }
853
854        Ok(before - items.len())
855    }
856}
857
858// ============================================================================
859// File-Based Memory Store
860// ============================================================================
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
863struct IndexEntry {
864    id: String,
865    content_lower: String,
866    tags: Vec<String>,
867    importance: f32,
868    timestamp: DateTime<Utc>,
869    memory_type: MemoryType,
870}
871
872impl From<&MemoryItem> for IndexEntry {
873    fn from(item: &MemoryItem) -> Self {
874        Self {
875            id: item.id.clone(),
876            content_lower: item.content.to_lowercase(),
877            tags: item.tags.clone(),
878            importance: item.importance,
879            timestamp: item.timestamp,
880            memory_type: item.memory_type,
881        }
882    }
883}
884
885/// File-based memory store with atomic writes and in-memory index.
886///
887/// ```text
888/// memory_dir/
889///   index.json
890///   items/{id}.json
891/// ```
892pub struct FileMemoryStore {
893    items_dir: std::path::PathBuf,
894    index_path: std::path::PathBuf,
895    index: RwLock<Vec<IndexEntry>>,
896}
897
898static FILE_MEMORY_INDEX_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
899
900fn file_memory_index_lock() -> &'static tokio::sync::Mutex<()> {
901    FILE_MEMORY_INDEX_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
902}
903
904impl FileMemoryStore {
905    pub async fn new(dir: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
906        let dir = dir.as_ref().to_path_buf();
907        let items_dir = dir.join("items");
908        let index_path = dir.join("index.json");
909
910        tokio::fs::create_dir_all(&items_dir)
911            .await
912            .with_context(|| {
913                format!("Failed to create memory directory: {}", items_dir.display())
914            })?;
915
916        let index = if index_path.exists() {
917            let data = tokio::fs::read_to_string(&index_path)
918                .await
919                .with_context(|| {
920                    format!("Failed to read memory index: {}", index_path.display())
921                })?;
922            serde_json::from_str(&data).unwrap_or_default()
923        } else {
924            Vec::new()
925        };
926
927        Ok(Self {
928            items_dir,
929            index_path,
930            index: RwLock::new(index),
931        })
932    }
933
934    fn safe_id(id: &str) -> String {
935        id.replace(['/', '\\'], "_").replace("..", "_")
936    }
937
938    fn item_path(&self, id: &str) -> std::path::PathBuf {
939        self.items_dir.join(format!("{}.json", Self::safe_id(id)))
940    }
941
942    async fn read_index_from_disk(&self) -> anyhow::Result<Vec<IndexEntry>> {
943        if !self.index_path.exists() {
944            return Ok(Vec::new());
945        }
946        let data = tokio::fs::read_to_string(&self.index_path)
947            .await
948            .with_context(|| {
949                format!("Failed to read memory index: {}", self.index_path.display())
950            })?;
951        Ok(serde_json::from_str(&data).unwrap_or_default())
952    }
953
954    async fn current_index(&self) -> Vec<IndexEntry> {
955        match self.read_index_from_disk().await {
956            Ok(index) => {
957                *self.index.write().await = index.clone();
958                index
959            }
960            Err(_) => self.index.read().await.clone(),
961        }
962    }
963
964    async fn write_index_entries(&self, index: &[IndexEntry]) -> anyhow::Result<()> {
965        let json = serde_json::to_string(index).context("Failed to serialize memory index")?;
966        let tmp = self
967            .index_path
968            .with_extension(format!("json.{}.tmp", uuid::Uuid::new_v4()));
969        tokio::fs::write(&tmp, json.as_bytes())
970            .await
971            .context("Failed to write memory index temp file")?;
972        tokio::fs::rename(&tmp, &self.index_path)
973            .await
974            .context("Failed to rename memory index")?;
975        Ok(())
976    }
977
978    async fn save_index(&self) -> anyhow::Result<()> {
979        let index = self.index.read().await.clone();
980        self.write_index_entries(&index).await
981    }
982
983    async fn save_item(&self, item: &MemoryItem) -> anyhow::Result<()> {
984        let path = self.item_path(&item.id);
985        let json = serde_json::to_string_pretty(item)
986            .with_context(|| format!("Failed to serialize memory item: {}", item.id))?;
987        let tmp = path.with_extension("json.tmp");
988        tokio::fs::write(&tmp, json.as_bytes())
989            .await
990            .with_context(|| format!("Failed to write memory item: {}", item.id))?;
991        tokio::fs::rename(&tmp, &path)
992            .await
993            .with_context(|| format!("Failed to rename memory item: {}", item.id))?;
994        Ok(())
995    }
996
997    async fn load_item_without_access(&self, id: &str) -> anyhow::Result<Option<MemoryItem>> {
998        let path = self.item_path(id);
999        if !path.exists() {
1000            return Ok(None);
1001        }
1002        let data = tokio::fs::read_to_string(&path).await?;
1003        let item: MemoryItem = serde_json::from_str(&data)?;
1004        Ok(Some(normalize_item_for_store(item)))
1005    }
1006
1007    /// Rebuild the index from item files on disk (useful for corruption recovery).
1008    pub async fn rebuild_index(&self) -> anyhow::Result<usize> {
1009        let _guard = file_memory_index_lock().lock().await;
1010        let mut entries = tokio::fs::read_dir(&self.items_dir).await?;
1011        let mut new_index = Vec::new();
1012        while let Some(entry) = entries.next_entry().await? {
1013            let path = entry.path();
1014            if path.extension().is_some_and(|ext| ext == "json") {
1015                if let Ok(data) = tokio::fs::read_to_string(&path).await {
1016                    if let Ok(item) = serde_json::from_str::<MemoryItem>(&data) {
1017                        new_index.push(IndexEntry::from(&item));
1018                    }
1019                }
1020            }
1021        }
1022        let count = new_index.len();
1023        self.write_index_entries(&new_index).await?;
1024        *self.index.write().await = new_index;
1025        Ok(count)
1026    }
1027}
1028
1029#[async_trait::async_trait]
1030impl MemoryStore for FileMemoryStore {
1031    async fn store(&self, item: MemoryItem) -> anyhow::Result<()> {
1032        self.store_and_return(item).await.map(|_| ())
1033    }
1034
1035    async fn store_and_return(&self, item: MemoryItem) -> anyhow::Result<MemoryItem> {
1036        let _guard = file_memory_index_lock().lock().await;
1037        let mut item = normalize_item_for_store(item);
1038        item.id = Self::safe_id(&item.id);
1039        let mut index = self.read_index_from_disk().await.unwrap_or_default();
1040
1041        let duplicate_id = index
1042            .iter()
1043            .find(|entry| memory_index_entry_is_duplicate(entry, &item))
1044            .map(|entry| entry.id.clone());
1045        if let Some(duplicate_id) = duplicate_id {
1046            if duplicate_id != item.id {
1047                if let Some(existing) = self.load_item_without_access(&duplicate_id).await? {
1048                    if memories_are_store_duplicates(&existing, &item) {
1049                        let merged = merge_duplicate_memory_item(existing, item.clone());
1050                        self.save_item(&merged).await?;
1051                        if item.id != merged.id {
1052                            let stale_path = self.item_path(&item.id);
1053                            if stale_path.exists() {
1054                                let _ = tokio::fs::remove_file(stale_path).await;
1055                            }
1056                        }
1057                        index.retain(|entry| entry.id != item.id && entry.id != merged.id);
1058                        index.push(IndexEntry::from(&merged));
1059                        self.write_index_entries(&index).await?;
1060                        *self.index.write().await = index;
1061                        return Ok(merged);
1062                    }
1063                }
1064            }
1065        }
1066
1067        self.save_item(&item).await?;
1068        let entry = IndexEntry::from(&item);
1069        if let Some(pos) = index.iter().position(|e| e.id == item.id) {
1070            index[pos] = entry;
1071        } else {
1072            index.push(entry);
1073        }
1074        self.write_index_entries(&index).await?;
1075        *self.index.write().await = index;
1076        Ok(item)
1077    }
1078
1079    async fn retrieve(&self, id: &str) -> anyhow::Result<Option<MemoryItem>> {
1080        let path = self.item_path(id);
1081        if !path.exists() {
1082            return Ok(None);
1083        }
1084        let data = tokio::fs::read_to_string(&path).await?;
1085        let mut item: MemoryItem = serde_json::from_str(&data)?;
1086        item.content_lower = item.content.to_lowercase();
1087        item.record_access();
1088        self.save_item(&item).await?;
1089        Ok(Some(item))
1090    }
1091
1092    async fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
1093        let query_lower = query.to_lowercase();
1094        let index = self.current_index().await;
1095        let now = Utc::now();
1096        let config = RelevanceConfig::default();
1097        let terms = query_terms(&query_lower);
1098        let mut matches: Vec<(&IndexEntry, f32)> = index
1099            .iter()
1100            .filter_map(|e| {
1101                Some((
1102                    e,
1103                    index_search_score(e, now, &config, &query_lower, &terms)?,
1104                ))
1105            })
1106            .collect();
1107        matches.sort_by(|a, b| {
1108            b.1.partial_cmp(&a.1)
1109                .unwrap_or(std::cmp::Ordering::Equal)
1110                .then_with(|| b.0.timestamp.cmp(&a.0.timestamp))
1111        });
1112        let ids: Vec<String> = matches
1113            .iter()
1114            .take(limit)
1115            .map(|(e, _)| e.id.clone())
1116            .collect();
1117        let mut items = Vec::with_capacity(ids.len());
1118        for id in ids {
1119            if let Some(item) = self.retrieve(&id).await? {
1120                items.push(item);
1121            }
1122        }
1123        Ok(items)
1124    }
1125
1126    async fn search_by_tags(
1127        &self,
1128        tags: &[String],
1129        limit: usize,
1130    ) -> anyhow::Result<Vec<MemoryItem>> {
1131        let index = self.current_index().await;
1132        let now = Utc::now();
1133        let config = RelevanceConfig::default();
1134        let mut matches: Vec<&IndexEntry> = index
1135            .iter()
1136            .filter(|e| tags.iter().any(|t| e.tags.contains(t)))
1137            .collect();
1138        matches.sort_by(|a, b| {
1139            index_score(a, now, &config)
1140                .partial_cmp(&index_score(b, now, &config))
1141                .unwrap_or(std::cmp::Ordering::Equal)
1142                .reverse()
1143        });
1144        let ids: Vec<String> = matches.iter().take(limit).map(|e| e.id.clone()).collect();
1145        let mut items = Vec::with_capacity(ids.len());
1146        for id in ids {
1147            if let Some(item) = self.retrieve(&id).await? {
1148                items.push(item);
1149            }
1150        }
1151        sort_by_relevance(&mut items);
1152        Ok(items)
1153    }
1154
1155    async fn get_recent(&self, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
1156        let index = self.current_index().await;
1157        let mut sorted: Vec<&IndexEntry> = index.iter().collect();
1158        sorted.sort_by_key(|entry| std::cmp::Reverse(entry.timestamp));
1159        let ids: Vec<String> = sorted.iter().take(limit).map(|e| e.id.clone()).collect();
1160        let mut items = Vec::with_capacity(ids.len());
1161        for id in ids {
1162            if let Some(item) = self.retrieve(&id).await? {
1163                items.push(item);
1164            }
1165        }
1166        items.sort_by_key(|item| std::cmp::Reverse(item.timestamp));
1167        Ok(items)
1168    }
1169
1170    async fn get_important(&self, threshold: f32, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
1171        let index = self.current_index().await;
1172        let mut matches: Vec<&IndexEntry> =
1173            index.iter().filter(|e| e.importance >= threshold).collect();
1174        matches.sort_by(|a, b| {
1175            b.importance
1176                .partial_cmp(&a.importance)
1177                .unwrap_or(std::cmp::Ordering::Equal)
1178        });
1179        let ids: Vec<String> = matches.iter().take(limit).map(|e| e.id.clone()).collect();
1180        let mut items = Vec::with_capacity(ids.len());
1181        for id in ids {
1182            if let Some(item) = self.retrieve(&id).await? {
1183                items.push(item);
1184            }
1185        }
1186        items.sort_by(|a, b| {
1187            b.importance
1188                .partial_cmp(&a.importance)
1189                .unwrap_or(std::cmp::Ordering::Equal)
1190        });
1191        Ok(items)
1192    }
1193
1194    async fn delete(&self, id: &str) -> anyhow::Result<()> {
1195        let _guard = file_memory_index_lock().lock().await;
1196        let path = self.item_path(id);
1197        if path.exists() {
1198            tokio::fs::remove_file(&path).await?;
1199        }
1200        let mut index = self.read_index_from_disk().await.unwrap_or_default();
1201        index.retain(|e| e.id != id);
1202        self.write_index_entries(&index).await?;
1203        *self.index.write().await = index;
1204        Ok(())
1205    }
1206
1207    async fn clear(&self) -> anyhow::Result<()> {
1208        let _guard = file_memory_index_lock().lock().await;
1209        let mut entries = tokio::fs::read_dir(&self.items_dir).await?;
1210        while let Some(entry) = entries.next_entry().await? {
1211            let path = entry.path();
1212            if path.extension().is_some_and(|ext| ext == "json") {
1213                let _ = tokio::fs::remove_file(&path).await;
1214            }
1215        }
1216        self.index.write().await.clear();
1217        self.save_index().await
1218    }
1219
1220    async fn count(&self) -> anyhow::Result<usize> {
1221        Ok(self.current_index().await.len())
1222    }
1223
1224    async fn prune(&self, policy: &PrunePolicy) -> anyhow::Result<usize> {
1225        let now = Utc::now();
1226        let cutoff = now - chrono::Duration::days(policy.max_age_days as i64);
1227        let min_importance = policy.min_importance_to_keep;
1228
1229        // Phase 1: collect IDs that are old and low-importance, unless the full
1230        // item carries curation/provenance protection.
1231        let mut items = Vec::new();
1232        for entry in self.current_index().await {
1233            if let Some(item) = self.load_item_without_access(&entry.id).await? {
1234                items.push(item);
1235            }
1236        }
1237        let phase1_ids: Vec<String> = items
1238            .iter()
1239            .filter(|item| {
1240                !memory_is_prune_protected(item)
1241                    && item.importance < min_importance
1242                    && item.timestamp < cutoff
1243            })
1244            .map(|item| item.id.clone())
1245            .collect();
1246        let mut deleted = phase1_ids.len();
1247        for id in &phase1_ids {
1248            self.delete(id).await?;
1249        }
1250
1251        // Phase 2: enforce max_items cap by removing lowest-relevance
1252        // unprotected items. Protected items are hard-exempt, so the store may
1253        // remain above the cap if the user pinned more memories than the cap.
1254        if policy.max_items > 0 {
1255            let config = RelevanceConfig::default();
1256            let phase2_ids: Vec<String> = {
1257                let mut remaining = Vec::new();
1258                for entry in self.current_index().await {
1259                    if let Some(item) = self.load_item_without_access(&entry.id).await? {
1260                        remaining.push(item);
1261                    }
1262                }
1263                if remaining.len() <= policy.max_items {
1264                    Vec::new()
1265                } else {
1266                    let protected_count = remaining
1267                        .iter()
1268                        .filter(|item| memory_is_prune_protected(item))
1269                        .count();
1270                    let unprotected_to_keep = policy.max_items.saturating_sub(protected_count);
1271                    let mut unprotected: Vec<MemoryItem> = remaining
1272                        .into_iter()
1273                        .filter(|item| !memory_is_prune_protected(item))
1274                        .collect();
1275                    unprotected.sort_by(|a, b| {
1276                        b.relevance_score_at(now, &config)
1277                            .partial_cmp(&a.relevance_score_at(now, &config))
1278                            .unwrap_or(std::cmp::Ordering::Equal)
1279                    });
1280                    unprotected
1281                        .into_iter()
1282                        .skip(unprotected_to_keep)
1283                        .map(|item| item.id)
1284                        .collect()
1285                }
1286            };
1287            deleted += phase2_ids.len();
1288            for id in &phase2_ids {
1289                self.delete(id).await?;
1290            }
1291        }
1292
1293        Ok(deleted)
1294    }
1295}
1296
1297// ============================================================================
1298// Tests
1299// ============================================================================
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304
1305    // MemoryItem tests
1306
1307    #[test]
1308    fn test_memory_item_creation() {
1309        let item = MemoryItem::new("Test memory")
1310            .with_importance(0.8)
1311            .with_tag("test")
1312            .with_type(MemoryType::Semantic);
1313        assert_eq!(item.content, "Test memory");
1314        assert_eq!(item.importance, 0.8);
1315        assert_eq!(item.tags, vec!["test"]);
1316        assert_eq!(item.memory_type, MemoryType::Semantic);
1317    }
1318
1319    #[test]
1320    fn test_memory_item_importance_clamped() {
1321        assert_eq!(MemoryItem::new("x").with_importance(1.5).importance, 1.0);
1322        assert_eq!(MemoryItem::new("x").with_importance(-0.5).importance, 0.0);
1323    }
1324
1325    #[test]
1326    fn test_memory_item_record_access() {
1327        let mut item = MemoryItem::new("test");
1328        assert_eq!(item.access_count, 0);
1329        item.record_access();
1330        assert_eq!(item.access_count, 1);
1331        assert!(item.last_accessed.is_some());
1332    }
1333
1334    #[test]
1335    fn test_memory_item_merge_duplicate_preserves_canonical_id() {
1336        let existing = MemoryItem::new("Run focused memory store tests after parser changes.")
1337            .with_importance(0.4)
1338            .with_tag("memory")
1339            .with_metadata("source", "workflow");
1340        let existing_id = existing.id.clone();
1341        let incoming =
1342            MemoryItem::new("Run focused memory store regression tests after parser changes.")
1343                .with_importance(0.9)
1344                .with_tag("tests")
1345                .with_metadata("supersedes", "old-memory");
1346
1347        let merged = existing.merge_duplicate(incoming);
1348
1349        assert_eq!(merged.id, existing_id);
1350        assert!(merged.content.contains("regression tests"));
1351        assert_eq!(merged.importance, 0.9);
1352        assert!(merged.tags.contains(&"memory".to_string()));
1353        assert!(merged.tags.contains(&"tests".to_string()));
1354        assert_eq!(
1355            merged.metadata.get("duplicate_count").map(String::as_str),
1356            Some("1")
1357        );
1358        assert_eq!(
1359            merged.metadata.get("supersedes").map(String::as_str),
1360            Some("old-memory")
1361        );
1362    }
1363
1364    #[test]
1365    fn test_memory_item_default_type_is_episodic() {
1366        assert_eq!(MemoryItem::new("test").memory_type, MemoryType::Episodic);
1367    }
1368
1369    #[test]
1370    fn test_memory_item_all_types() {
1371        assert_eq!(
1372            MemoryItem::new("e")
1373                .with_type(MemoryType::Episodic)
1374                .memory_type,
1375            MemoryType::Episodic
1376        );
1377        assert_eq!(
1378            MemoryItem::new("s")
1379                .with_type(MemoryType::Semantic)
1380                .memory_type,
1381            MemoryType::Semantic
1382        );
1383        assert_eq!(
1384            MemoryItem::new("p")
1385                .with_type(MemoryType::Procedural)
1386                .memory_type,
1387            MemoryType::Procedural
1388        );
1389        assert_eq!(
1390            MemoryItem::new("w")
1391                .with_type(MemoryType::Working)
1392                .memory_type,
1393            MemoryType::Working
1394        );
1395    }
1396
1397    // relevance_score_at tests
1398
1399    #[test]
1400    fn test_relevance_score_uses_config() {
1401        let item = MemoryItem::new("test").with_importance(1.0);
1402        let now = Utc::now();
1403
1404        // High importance weight → score dominated by importance
1405        let config_importance = RelevanceConfig {
1406            decay_days: 30.0,
1407            importance_weight: 0.9,
1408            recency_weight: 0.1,
1409        };
1410        let score = item.relevance_score_at(now, &config_importance);
1411        assert!(score > 0.95, "score was {score}");
1412
1413        // Short decay → recent item still scores well
1414        let config_fast_decay = RelevanceConfig {
1415            decay_days: 1.0,
1416            importance_weight: 0.7,
1417            recency_weight: 0.3,
1418        };
1419        let score2 = item.relevance_score_at(now, &config_fast_decay);
1420        assert!(score2 > 0.9, "score was {score2}");
1421    }
1422
1423    #[test]
1424    fn test_relevance_score_decays_with_age() {
1425        let mut old_item = MemoryItem::new("old").with_importance(0.5);
1426        old_item.timestamp = Utc::now() - chrono::Duration::days(60);
1427        let config = RelevanceConfig::default(); // 30-day half-life
1428        let score = old_item.relevance_score_at(Utc::now(), &config);
1429        // After 60 days (2 half-lives), decay ≈ exp(-2) ≈ 0.135
1430        // score ≈ 0.5*0.7 + 0.135*0.3 ≈ 0.39
1431        assert!(score < 0.45, "score was {score}");
1432    }
1433
1434    #[test]
1435    fn test_relevance_score_default_uses_default_config() {
1436        let item = MemoryItem::new("test").with_importance(0.9);
1437        let score = item.relevance_score();
1438        assert!(score > 0.6);
1439    }
1440
1441    // RelevanceConfig tests
1442
1443    #[test]
1444    fn test_relevance_config_defaults() {
1445        let c = RelevanceConfig::default();
1446        assert_eq!(c.decay_days, 30.0);
1447        assert_eq!(c.importance_weight, 0.7);
1448        assert_eq!(c.recency_weight, 0.3);
1449    }
1450
1451    // InMemoryStore tests
1452
1453    #[tokio::test]
1454    async fn test_in_memory_store_retrieve() {
1455        let store = InMemoryStore::new();
1456        let item = MemoryItem::new("hello").with_tag("test");
1457        store.store(item.clone()).await.unwrap();
1458        let r = store.retrieve(&item.id).await.unwrap();
1459        assert!(r.is_some());
1460        assert_eq!(r.unwrap().content, "hello");
1461        let r = store.retrieve(&item.id).await.unwrap().unwrap();
1462        assert_eq!(r.access_count, 2);
1463    }
1464
1465    #[tokio::test]
1466    async fn test_in_memory_store_retrieve_nonexistent() {
1467        let store = InMemoryStore::new();
1468        assert!(store.retrieve("nope").await.unwrap().is_none());
1469    }
1470
1471    #[tokio::test]
1472    async fn test_in_memory_store_upsert() {
1473        let store = InMemoryStore::new();
1474        let mut item = MemoryItem::new("original");
1475        let id = item.id.clone();
1476        store.store(item.clone()).await.unwrap();
1477        item.content = "updated".to_string();
1478        item.content_lower = "updated".to_string();
1479        store.store(item).await.unwrap();
1480        assert_eq!(store.count().await.unwrap(), 1);
1481        assert_eq!(
1482            store.retrieve(&id).await.unwrap().unwrap().content,
1483            "updated"
1484        );
1485    }
1486
1487    #[tokio::test]
1488    async fn test_in_memory_store_search_and_tags() {
1489        let store = InMemoryStore::new();
1490        store
1491            .store(MemoryItem::new("create file").with_tag("file"))
1492            .await
1493            .unwrap();
1494        store
1495            .store(MemoryItem::new("delete file").with_tag("file"))
1496            .await
1497            .unwrap();
1498        store
1499            .store(MemoryItem::new("create dir").with_tag("dir"))
1500            .await
1501            .unwrap();
1502        assert_eq!(store.search("create", 10).await.unwrap().len(), 2);
1503        assert_eq!(
1504            store
1505                .search_by_tags(&["file".to_string()], 10)
1506                .await
1507                .unwrap()
1508                .len(),
1509            2
1510        );
1511    }
1512
1513    #[tokio::test]
1514    async fn test_in_memory_store_search_relevance_order() {
1515        let store = InMemoryStore::new();
1516        store
1517            .store(MemoryItem::new("rust tip").with_importance(0.3))
1518            .await
1519            .unwrap();
1520        store
1521            .store(MemoryItem::new("rust trick").with_importance(0.9))
1522            .await
1523            .unwrap();
1524        let results = store.search("rust", 10).await.unwrap();
1525        assert_eq!(results.len(), 2);
1526        assert!(results[0].importance >= results[1].importance);
1527    }
1528
1529    #[tokio::test]
1530    async fn test_in_memory_store_delete_and_clear() {
1531        let store = InMemoryStore::new();
1532        let item = MemoryItem::new("to delete");
1533        let id = item.id.clone();
1534        store.store(item).await.unwrap();
1535        store.delete(&id).await.unwrap();
1536        assert_eq!(store.count().await.unwrap(), 0);
1537
1538        for i in 0..3 {
1539            store
1540                .store(MemoryItem::new(format!("item {i}")))
1541                .await
1542                .unwrap();
1543        }
1544        store.clear().await.unwrap();
1545        assert_eq!(store.count().await.unwrap(), 0);
1546    }
1547
1548    #[tokio::test]
1549    async fn test_in_memory_store_get_recent() {
1550        let store = InMemoryStore::new();
1551        for i in 0..5 {
1552            let mut item = MemoryItem::new(format!("item {i}"));
1553            item.timestamp = Utc::now() + chrono::Duration::seconds(i as i64);
1554            store.store(item).await.unwrap();
1555        }
1556        let recent = store.get_recent(3).await.unwrap();
1557        assert_eq!(recent.len(), 3);
1558        assert!(recent[0].timestamp >= recent[1].timestamp);
1559    }
1560
1561    #[tokio::test]
1562    async fn test_in_memory_store_get_important() {
1563        let store = InMemoryStore::new();
1564        store
1565            .store(MemoryItem::new("low").with_importance(0.2))
1566            .await
1567            .unwrap();
1568        store
1569            .store(MemoryItem::new("high").with_importance(0.9))
1570            .await
1571            .unwrap();
1572        store
1573            .store(MemoryItem::new("medium").with_importance(0.5))
1574            .await
1575            .unwrap();
1576        let results = store.get_important(0.7, 10).await.unwrap();
1577        assert_eq!(results.len(), 1);
1578        assert_eq!(results[0].content, "high");
1579    }
1580
1581    #[test]
1582    fn test_in_memory_store_default() {
1583        let _store: InMemoryStore = InMemoryStore::default();
1584    }
1585
1586    // PrunePolicy / prune() tests
1587
1588    #[test]
1589    fn test_prune_policy_defaults() {
1590        let p = PrunePolicy::default();
1591        assert_eq!(p.max_age_days, 90);
1592        assert_eq!(p.min_importance_to_keep, 0.5);
1593        assert_eq!(p.max_items, 0);
1594    }
1595
1596    #[tokio::test]
1597    async fn test_prune_removes_old_low_importance() {
1598        let store = InMemoryStore::new();
1599        let mut old_item = MemoryItem::new("stale memory").with_importance(0.2);
1600        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
1601        store.store(old_item).await.unwrap();
1602
1603        let policy = PrunePolicy {
1604            max_age_days: 90,
1605            min_importance_to_keep: 0.5,
1606            max_items: 0,
1607        };
1608        let deleted = store.prune(&policy).await.unwrap();
1609        assert_eq!(deleted, 1);
1610        assert_eq!(store.count().await.unwrap(), 0);
1611    }
1612
1613    #[tokio::test]
1614    async fn test_prune_keeps_high_importance() {
1615        let store = InMemoryStore::new();
1616        let mut old_item = MemoryItem::new("important memory").with_importance(0.9);
1617        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
1618        store.store(old_item).await.unwrap();
1619
1620        let policy = PrunePolicy {
1621            max_age_days: 90,
1622            min_importance_to_keep: 0.5,
1623            max_items: 0,
1624        };
1625        let deleted = store.prune(&policy).await.unwrap();
1626        assert_eq!(deleted, 0);
1627        assert_eq!(store.count().await.unwrap(), 1);
1628    }
1629
1630    #[tokio::test]
1631    async fn test_prune_max_items() {
1632        let store = InMemoryStore::new();
1633        for i in 0..10 {
1634            store
1635                .store(MemoryItem::new(format!("item {i}")).with_importance(i as f32 * 0.1))
1636                .await
1637                .unwrap();
1638        }
1639        let policy = PrunePolicy {
1640            max_age_days: 9999,
1641            min_importance_to_keep: 0.0,
1642            max_items: 5,
1643        };
1644        let deleted = store.prune(&policy).await.unwrap();
1645        assert_eq!(deleted, 5);
1646        assert_eq!(store.count().await.unwrap(), 5);
1647    }
1648
1649    #[tokio::test]
1650    async fn test_prune_keeps_recent_low_importance() {
1651        // A recent item with low importance should NOT be pruned (not old enough)
1652        let store = InMemoryStore::new();
1653        store
1654            .store(MemoryItem::new("fresh").with_importance(0.1))
1655            .await
1656            .unwrap();
1657
1658        let policy = PrunePolicy {
1659            max_age_days: 90,
1660            min_importance_to_keep: 0.5,
1661            max_items: 0,
1662        };
1663        let deleted = store.prune(&policy).await.unwrap();
1664        assert_eq!(deleted, 0);
1665        assert_eq!(store.count().await.unwrap(), 1);
1666    }
1667}
1668
1669#[cfg(test)]
1670mod file_memory_store_tests {
1671    use super::*;
1672    use tempfile::TempDir;
1673
1674    async fn setup() -> (TempDir, FileMemoryStore) {
1675        let dir = TempDir::new().unwrap();
1676        let store = FileMemoryStore::new(dir.path()).await.unwrap();
1677        (dir, store)
1678    }
1679
1680    #[tokio::test]
1681    async fn test_store_and_retrieve() {
1682        let (_dir, store) = setup().await;
1683        let item = MemoryItem::new("hello world");
1684        let id = item.id.clone();
1685        store.store(item).await.unwrap();
1686        let r = store.retrieve(&id).await.unwrap().unwrap();
1687        assert_eq!(r.content, "hello world");
1688    }
1689
1690    #[tokio::test]
1691    async fn test_retrieve_nonexistent() {
1692        let (_dir, store) = setup().await;
1693        assert!(store.retrieve("nonexistent").await.unwrap().is_none());
1694    }
1695
1696    #[tokio::test]
1697    async fn test_search_by_content() {
1698        let (_dir, store) = setup().await;
1699        store
1700            .store(MemoryItem::new("rust programming"))
1701            .await
1702            .unwrap();
1703        store
1704            .store(MemoryItem::new("python scripting"))
1705            .await
1706            .unwrap();
1707        store
1708            .store(MemoryItem::new("rust async patterns"))
1709            .await
1710            .unwrap();
1711        let results = store.search("rust", 10).await.unwrap();
1712        assert_eq!(results.len(), 2);
1713    }
1714
1715    #[tokio::test]
1716    async fn test_search_matches_non_contiguous_terms() {
1717        let (_dir, store) = setup().await;
1718        store
1719            .store(MemoryItem::new(
1720                "Success: release preflight\nTools: bash\nResult: provider verification passed",
1721            ))
1722            .await
1723            .unwrap();
1724        let results = store.search("release provider check", 10).await.unwrap();
1725        assert_eq!(results.len(), 1);
1726        assert!(results[0].content.contains("release preflight"));
1727    }
1728
1729    #[tokio::test]
1730    async fn test_retrieve_records_access_on_disk() {
1731        let (dir, store) = setup().await;
1732        let item = MemoryItem::new("access me");
1733        let id = item.id.clone();
1734        store.store(item).await.unwrap();
1735
1736        let item = store.retrieve(&id).await.unwrap().unwrap();
1737        assert_eq!(item.access_count, 1);
1738
1739        let reopened = FileMemoryStore::new(dir.path()).await.unwrap();
1740        let item = reopened.retrieve(&id).await.unwrap().unwrap();
1741        assert_eq!(item.access_count, 2);
1742        assert!(item.last_accessed.is_some());
1743    }
1744
1745    #[tokio::test]
1746    async fn test_search_limit() {
1747        let (_dir, store) = setup().await;
1748        for i in 0..10 {
1749            store
1750                .store(MemoryItem::new(format!("item {i}")))
1751                .await
1752                .unwrap();
1753        }
1754        assert_eq!(store.search("item", 3).await.unwrap().len(), 3);
1755    }
1756
1757    #[tokio::test]
1758    async fn test_search_by_tags() {
1759        let (_dir, store) = setup().await;
1760        store
1761            .store(MemoryItem::new("one").with_tags(vec!["rust".into(), "async".into()]))
1762            .await
1763            .unwrap();
1764        store
1765            .store(MemoryItem::new("two").with_tags(vec!["python".into()]))
1766            .await
1767            .unwrap();
1768        store
1769            .store(MemoryItem::new("three").with_tags(vec!["rust".into()]))
1770            .await
1771            .unwrap();
1772        assert_eq!(
1773            store
1774                .search_by_tags(&["rust".to_string()], 10)
1775                .await
1776                .unwrap()
1777                .len(),
1778            2
1779        );
1780    }
1781
1782    #[tokio::test]
1783    async fn test_get_recent_ordered() {
1784        let (_dir, store) = setup().await;
1785        for i in 0..5 {
1786            let mut item = MemoryItem::new(format!("item {i}"));
1787            item.timestamp = Utc::now() + chrono::Duration::seconds(i as i64);
1788            store.store(item).await.unwrap();
1789        }
1790        let results = store.get_recent(3).await.unwrap();
1791        assert_eq!(results.len(), 3);
1792        assert!(results[0].timestamp >= results[1].timestamp);
1793    }
1794
1795    #[tokio::test]
1796    async fn test_get_important() {
1797        let (_dir, store) = setup().await;
1798        store
1799            .store(MemoryItem::new("low").with_importance(0.1))
1800            .await
1801            .unwrap();
1802        store
1803            .store(MemoryItem::new("high").with_importance(0.9))
1804            .await
1805            .unwrap();
1806        store
1807            .store(MemoryItem::new("medium").with_importance(0.5))
1808            .await
1809            .unwrap();
1810        let results = store.get_important(0.0, 2).await.unwrap();
1811        assert_eq!(results.len(), 2);
1812        assert!(results[0].importance >= results[1].importance);
1813    }
1814
1815    #[tokio::test]
1816    async fn test_delete() {
1817        let (_dir, store) = setup().await;
1818        let item = MemoryItem::new("to delete");
1819        let id = item.id.clone();
1820        store.store(item).await.unwrap();
1821        store.delete(&id).await.unwrap();
1822        assert_eq!(store.count().await.unwrap(), 0);
1823        assert!(store.retrieve(&id).await.unwrap().is_none());
1824    }
1825
1826    #[tokio::test]
1827    async fn test_delete_nonexistent() {
1828        let (_dir, store) = setup().await;
1829        store.delete("nonexistent").await.unwrap();
1830    }
1831
1832    #[tokio::test]
1833    async fn test_clear() {
1834        let (_dir, store) = setup().await;
1835        for i in 0..5 {
1836            store
1837                .store(MemoryItem::new(format!("item {i}")))
1838                .await
1839                .unwrap();
1840        }
1841        store.clear().await.unwrap();
1842        assert_eq!(store.count().await.unwrap(), 0);
1843    }
1844
1845    #[tokio::test]
1846    async fn test_persistence_across_instances() {
1847        let dir = TempDir::new().unwrap();
1848        {
1849            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1850            store
1851                .store(MemoryItem::new("persistent data").with_tags(vec!["test".into()]))
1852                .await
1853                .unwrap();
1854        }
1855        {
1856            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1857            assert_eq!(store.count().await.unwrap(), 1);
1858            assert_eq!(store.search("persistent", 10).await.unwrap().len(), 1);
1859        }
1860    }
1861
1862    #[tokio::test]
1863    async fn test_stale_instances_merge_index_on_store() {
1864        let dir = TempDir::new().unwrap();
1865        let store_a = FileMemoryStore::new(dir.path()).await.unwrap();
1866        let store_b = FileMemoryStore::new(dir.path()).await.unwrap();
1867
1868        store_a
1869            .store(MemoryItem::new("alpha stale merge"))
1870            .await
1871            .unwrap();
1872        store_b
1873            .store(MemoryItem::new("beta stale merge"))
1874            .await
1875            .unwrap();
1876
1877        let reopened = FileMemoryStore::new(dir.path()).await.unwrap();
1878        assert_eq!(reopened.count().await.unwrap(), 2);
1879        assert_eq!(reopened.search("stale merge", 10).await.unwrap().len(), 2);
1880    }
1881
1882    #[tokio::test]
1883    async fn test_rebuild_index() {
1884        let dir = TempDir::new().unwrap();
1885        {
1886            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1887            store.store(MemoryItem::new("alpha")).await.unwrap();
1888            store.store(MemoryItem::new("beta")).await.unwrap();
1889        }
1890        tokio::fs::remove_file(dir.path().join("index.json"))
1891            .await
1892            .unwrap();
1893        {
1894            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1895            assert_eq!(store.count().await.unwrap(), 0);
1896            store.rebuild_index().await.unwrap();
1897            assert_eq!(store.count().await.unwrap(), 2);
1898        }
1899    }
1900
1901    #[tokio::test]
1902    async fn test_path_traversal_prevention() {
1903        let (_dir, store) = setup().await;
1904        let mut item = MemoryItem::new("sneaky");
1905        item.id = "../../../etc/passwd".to_string();
1906        store.store(item).await.unwrap();
1907        let results = store.search("sneaky", 10).await.unwrap();
1908        assert_eq!(results.len(), 1);
1909        assert!(!results[0].id.contains('/'));
1910        assert!(!results[0].id.contains(".."));
1911    }
1912
1913    #[tokio::test]
1914    async fn test_importance_threshold() {
1915        let (_dir, store) = setup().await;
1916        store
1917            .store(MemoryItem::new("low").with_importance(0.2))
1918            .await
1919            .unwrap();
1920        store
1921            .store(MemoryItem::new("high").with_importance(0.8))
1922            .await
1923            .unwrap();
1924        let results = store.get_important(0.5, 10).await.unwrap();
1925        assert_eq!(results.len(), 1);
1926        assert_eq!(results[0].content, "high");
1927    }
1928
1929    #[tokio::test]
1930    async fn test_file_prune_removes_old_low_importance() {
1931        let (_dir, store) = setup().await;
1932        let mut old_item = MemoryItem::new("stale").with_importance(0.2);
1933        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
1934        store.store(old_item).await.unwrap();
1935
1936        let policy = PrunePolicy {
1937            max_age_days: 90,
1938            min_importance_to_keep: 0.5,
1939            max_items: 0,
1940        };
1941        let deleted = store.prune(&policy).await.unwrap();
1942        assert_eq!(deleted, 1);
1943        assert_eq!(store.count().await.unwrap(), 0);
1944    }
1945
1946    #[tokio::test]
1947    async fn test_file_prune_keeps_high_importance() {
1948        let (_dir, store) = setup().await;
1949        let mut old_item = MemoryItem::new("important").with_importance(0.9);
1950        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
1951        store.store(old_item).await.unwrap();
1952
1953        let policy = PrunePolicy {
1954            max_age_days: 90,
1955            min_importance_to_keep: 0.5,
1956            max_items: 0,
1957        };
1958        let deleted = store.prune(&policy).await.unwrap();
1959        assert_eq!(deleted, 0);
1960        assert_eq!(store.count().await.unwrap(), 1);
1961    }
1962
1963    #[tokio::test]
1964    async fn test_file_prune_max_items() {
1965        let (_dir, store) = setup().await;
1966        for i in 0..10 {
1967            store
1968                .store(MemoryItem::new(format!("item {i}")).with_importance(i as f32 * 0.1))
1969                .await
1970                .unwrap();
1971        }
1972        let policy = PrunePolicy {
1973            max_age_days: 9999,
1974            min_importance_to_keep: 0.0,
1975            max_items: 5,
1976        };
1977        let deleted = store.prune(&policy).await.unwrap();
1978        assert_eq!(deleted, 5);
1979        assert_eq!(store.count().await.unwrap(), 5);
1980    }
1981}