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, a `FileMemoryStore` default implementation, and
5//! (with the `sqlite` feature) a `SqliteMemoryStore` backed by SQLite with
6//! FTS5 full-text search.
7
8/// SQLite-backed memory store with dual-track Markdown export.
9///
10/// Requires the `sqlite` Cargo feature.
11#[cfg(feature = "sqlite")]
12pub mod sqlite;
13
14#[cfg(feature = "sqlite")]
15pub use sqlite::SqliteMemoryStore;
16
17use anyhow::Context as _;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use tokio::sync::RwLock;
22
23// ============================================================================
24// Configuration
25// ============================================================================
26
27/// Configuration for relevance scoring
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct RelevanceConfig {
31    /// Exponential decay half-life in days (default: 30.0)
32    #[serde(default = "RelevanceConfig::default_decay_days")]
33    pub decay_days: f32,
34    /// Weight for importance factor (default: 0.7)
35    #[serde(default = "RelevanceConfig::default_importance_weight")]
36    pub importance_weight: f32,
37    /// Weight for recency factor (default: 0.3)
38    #[serde(default = "RelevanceConfig::default_recency_weight")]
39    pub recency_weight: f32,
40}
41
42impl RelevanceConfig {
43    fn default_decay_days() -> f32 {
44        30.0
45    }
46    fn default_importance_weight() -> f32 {
47        0.7
48    }
49    fn default_recency_weight() -> f32 {
50        0.3
51    }
52}
53
54impl Default for RelevanceConfig {
55    fn default() -> Self {
56        Self {
57            decay_days: 30.0,
58            importance_weight: 0.7,
59            recency_weight: 0.3,
60        }
61    }
62}
63
64/// Policy controlling automatic pruning of long-term memory.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PrunePolicy {
68    /// Items older than this many days AND below `min_importance_to_keep` are deleted (default: 90).
69    #[serde(default = "PrunePolicy::default_max_age_days")]
70    pub max_age_days: u32,
71    /// Items with importance below this threshold are eligible for age-based deletion (default: 0.5).
72    /// High-importance items are never age-pruned.
73    #[serde(default = "PrunePolicy::default_min_importance_to_keep")]
74    pub min_importance_to_keep: f32,
75    /// Hard cap on total items; when exceeded, lowest-relevance items are removed.
76    /// 0 means unlimited (default: 0).
77    #[serde(default)]
78    pub max_items: usize,
79}
80
81impl PrunePolicy {
82    fn default_max_age_days() -> u32 {
83        90
84    }
85    fn default_min_importance_to_keep() -> f32 {
86        0.5
87    }
88}
89
90impl Default for PrunePolicy {
91    fn default() -> Self {
92        Self {
93            max_age_days: 90,
94            min_importance_to_keep: 0.5,
95            max_items: 0,
96        }
97    }
98}
99
100// ============================================================================
101// Memory Item
102// ============================================================================
103
104/// A single memory item
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct MemoryItem {
107    pub id: String,
108    pub content: String,
109    pub timestamp: DateTime<Utc>,
110    pub importance: f32,
111    pub tags: Vec<String>,
112    pub memory_type: MemoryType,
113    pub metadata: HashMap<String, String>,
114    pub access_count: u32,
115    pub last_accessed: Option<DateTime<Utc>>,
116    #[serde(skip)]
117    pub content_lower: String,
118}
119
120impl MemoryItem {
121    pub fn new(content: impl Into<String>) -> Self {
122        let content = content.into();
123        let content_lower = content.to_lowercase();
124        Self {
125            id: uuid::Uuid::new_v4().to_string(),
126            content,
127            timestamp: Utc::now(),
128            importance: 0.5,
129            tags: Vec::new(),
130            memory_type: MemoryType::Episodic,
131            metadata: HashMap::new(),
132            access_count: 0,
133            last_accessed: None,
134            content_lower,
135        }
136    }
137
138    pub fn with_importance(mut self, importance: f32) -> Self {
139        self.importance = importance.clamp(0.0, 1.0);
140        self
141    }
142
143    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
144        self.tags = tags;
145        self
146    }
147
148    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
149        self.tags.push(tag.into());
150        self
151    }
152
153    pub fn with_type(mut self, memory_type: MemoryType) -> Self {
154        self.memory_type = memory_type;
155        self
156    }
157
158    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
159        self.metadata.insert(key.into(), value.into());
160        self
161    }
162
163    pub fn record_access(&mut self) {
164        self.access_count += 1;
165        self.last_accessed = Some(Utc::now());
166    }
167
168    /// Calculate relevance score at a given timestamp using the provided config
169    pub fn relevance_score_at(&self, now: DateTime<Utc>, config: &RelevanceConfig) -> f32 {
170        let age_days = (now - self.timestamp).num_seconds() as f32 / 86400.0;
171        let decay = (-age_days / config.decay_days).exp();
172        self.importance * config.importance_weight + decay * config.recency_weight
173    }
174
175    /// Calculate relevance score with default config
176    pub fn relevance_score(&self) -> f32 {
177        self.relevance_score_at(Utc::now(), &RelevanceConfig::default())
178    }
179}
180
181/// Type of memory
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum MemoryType {
185    Episodic,
186    Semantic,
187    Procedural,
188    Working,
189}
190
191// ============================================================================
192// Memory Store Trait
193// ============================================================================
194
195#[async_trait::async_trait]
196pub trait MemoryStore: Send + Sync {
197    async fn store(&self, item: MemoryItem) -> anyhow::Result<()>;
198    async fn retrieve(&self, id: &str) -> anyhow::Result<Option<MemoryItem>>;
199    async fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryItem>>;
200    async fn search_by_tags(
201        &self,
202        tags: &[String],
203        limit: usize,
204    ) -> anyhow::Result<Vec<MemoryItem>>;
205    async fn get_recent(&self, limit: usize) -> anyhow::Result<Vec<MemoryItem>>;
206    async fn get_important(&self, threshold: f32, limit: usize) -> anyhow::Result<Vec<MemoryItem>>;
207    async fn delete(&self, id: &str) -> anyhow::Result<()>;
208    async fn clear(&self) -> anyhow::Result<()>;
209    async fn count(&self) -> anyhow::Result<usize>;
210
211    /// Remove stale or excess items according to `policy`.
212    ///
213    /// Returns the number of items deleted. The default implementation is a
214    /// no-op (returns 0) for backwards compatibility.
215    async fn prune(&self, policy: &PrunePolicy) -> anyhow::Result<usize> {
216        let _ = policy;
217        Ok(0)
218    }
219}
220
221// ============================================================================
222// Shared helpers
223// ============================================================================
224
225/// Score an index entry for sorting (avoids loading full MemoryItem from disk)
226fn index_score(entry: &IndexEntry, now: DateTime<Utc>, config: &RelevanceConfig) -> f32 {
227    let age_days = (now - entry.timestamp).num_seconds() as f32 / 86400.0;
228    let decay = (-age_days / config.decay_days).exp();
229    entry.importance * config.importance_weight + decay * config.recency_weight
230}
231
232fn sort_by_relevance(items: &mut [MemoryItem]) {
233    let now = Utc::now();
234    let config = RelevanceConfig::default();
235    items.sort_by(|a, b| {
236        b.relevance_score_at(now, &config)
237            .partial_cmp(&a.relevance_score_at(now, &config))
238            .unwrap_or(std::cmp::Ordering::Equal)
239    });
240}
241
242// ============================================================================
243// In-Memory Store
244// ============================================================================
245
246/// In-memory `MemoryStore` implementation.
247///
248/// Useful for testing and ephemeral (non-persistent) use cases.
249pub struct InMemoryStore {
250    items: RwLock<Vec<MemoryItem>>,
251}
252
253impl Default for InMemoryStore {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259impl InMemoryStore {
260    pub fn new() -> Self {
261        Self {
262            items: RwLock::new(Vec::new()),
263        }
264    }
265}
266
267#[async_trait::async_trait]
268impl MemoryStore for InMemoryStore {
269    async fn store(&self, item: MemoryItem) -> anyhow::Result<()> {
270        let mut items = self.items.write().await;
271        if let Some(pos) = items.iter().position(|i| i.id == item.id) {
272            items[pos] = item;
273        } else {
274            items.push(item);
275        }
276        Ok(())
277    }
278
279    async fn retrieve(&self, id: &str) -> anyhow::Result<Option<MemoryItem>> {
280        Ok(self.items.read().await.iter().find(|i| i.id == id).cloned())
281    }
282
283    async fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
284        let query_lower = query.to_lowercase();
285        let config = RelevanceConfig::default();
286        let now = Utc::now();
287        let items = self.items.read().await;
288        let mut matches: Vec<MemoryItem> = items
289            .iter()
290            .filter(|i| i.content_lower.contains(&query_lower))
291            .cloned()
292            .collect();
293        matches.sort_by(|a, b| {
294            b.relevance_score_at(now, &config)
295                .partial_cmp(&a.relevance_score_at(now, &config))
296                .unwrap_or(std::cmp::Ordering::Equal)
297        });
298        matches.truncate(limit);
299        Ok(matches)
300    }
301
302    async fn search_by_tags(
303        &self,
304        tags: &[String],
305        limit: usize,
306    ) -> anyhow::Result<Vec<MemoryItem>> {
307        let config = RelevanceConfig::default();
308        let now = Utc::now();
309        let items = self.items.read().await;
310        let mut matches: Vec<MemoryItem> = items
311            .iter()
312            .filter(|i| tags.iter().any(|t| i.tags.contains(t)))
313            .cloned()
314            .collect();
315        matches.sort_by(|a, b| {
316            b.relevance_score_at(now, &config)
317                .partial_cmp(&a.relevance_score_at(now, &config))
318                .unwrap_or(std::cmp::Ordering::Equal)
319        });
320        matches.truncate(limit);
321        Ok(matches)
322    }
323
324    async fn get_recent(&self, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
325        let items = self.items.read().await;
326        let mut sorted: Vec<MemoryItem> = items.iter().cloned().collect();
327        sorted.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
328        sorted.truncate(limit);
329        Ok(sorted)
330    }
331
332    async fn get_important(&self, threshold: f32, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
333        let items = self.items.read().await;
334        let mut matches: Vec<MemoryItem> = items
335            .iter()
336            .filter(|i| i.importance >= threshold)
337            .cloned()
338            .collect();
339        matches.sort_by(|a, b| {
340            b.importance
341                .partial_cmp(&a.importance)
342                .unwrap_or(std::cmp::Ordering::Equal)
343        });
344        matches.truncate(limit);
345        Ok(matches)
346    }
347
348    async fn delete(&self, id: &str) -> anyhow::Result<()> {
349        self.items.write().await.retain(|i| i.id != id);
350        Ok(())
351    }
352
353    async fn clear(&self) -> anyhow::Result<()> {
354        self.items.write().await.clear();
355        Ok(())
356    }
357
358    async fn count(&self) -> anyhow::Result<usize> {
359        Ok(self.items.read().await.len())
360    }
361
362    async fn prune(&self, policy: &PrunePolicy) -> anyhow::Result<usize> {
363        let now = Utc::now();
364        let cutoff = now - chrono::Duration::days(policy.max_age_days as i64);
365        let min_importance = policy.min_importance_to_keep;
366
367        let mut items = self.items.write().await;
368        let before = items.len();
369
370        // Phase 1: remove items that are both old and below the importance threshold.
371        items.retain(|item| item.importance >= min_importance || item.timestamp >= cutoff);
372
373        // Phase 2: if still over the cap, keep the highest-relevance items.
374        if policy.max_items > 0 && items.len() > policy.max_items {
375            let config = RelevanceConfig::default();
376            items.sort_by(|a, b| {
377                b.relevance_score_at(now, &config)
378                    .partial_cmp(&a.relevance_score_at(now, &config))
379                    .unwrap_or(std::cmp::Ordering::Equal)
380            });
381            items.truncate(policy.max_items);
382        }
383
384        Ok(before - items.len())
385    }
386}
387
388// ============================================================================
389// File-Based Memory Store
390// ============================================================================
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393struct IndexEntry {
394    id: String,
395    content_lower: String,
396    tags: Vec<String>,
397    importance: f32,
398    timestamp: DateTime<Utc>,
399    memory_type: MemoryType,
400}
401
402impl From<&MemoryItem> for IndexEntry {
403    fn from(item: &MemoryItem) -> Self {
404        Self {
405            id: item.id.clone(),
406            content_lower: item.content.to_lowercase(),
407            tags: item.tags.clone(),
408            importance: item.importance,
409            timestamp: item.timestamp,
410            memory_type: item.memory_type,
411        }
412    }
413}
414
415/// File-based memory store with atomic writes and in-memory index.
416///
417/// ```text
418/// memory_dir/
419///   index.json
420///   items/{id}.json
421/// ```
422pub struct FileMemoryStore {
423    items_dir: std::path::PathBuf,
424    index_path: std::path::PathBuf,
425    index: RwLock<Vec<IndexEntry>>,
426}
427
428impl FileMemoryStore {
429    pub async fn new(dir: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
430        let dir = dir.as_ref().to_path_buf();
431        let items_dir = dir.join("items");
432        let index_path = dir.join("index.json");
433
434        tokio::fs::create_dir_all(&items_dir)
435            .await
436            .with_context(|| {
437                format!("Failed to create memory directory: {}", items_dir.display())
438            })?;
439
440        let index = if index_path.exists() {
441            let data = tokio::fs::read_to_string(&index_path)
442                .await
443                .with_context(|| {
444                    format!("Failed to read memory index: {}", index_path.display())
445                })?;
446            serde_json::from_str(&data).unwrap_or_default()
447        } else {
448            Vec::new()
449        };
450
451        Ok(Self {
452            items_dir,
453            index_path,
454            index: RwLock::new(index),
455        })
456    }
457
458    fn safe_id(id: &str) -> String {
459        id.replace(['/', '\\'], "_").replace("..", "_")
460    }
461
462    fn item_path(&self, id: &str) -> std::path::PathBuf {
463        self.items_dir.join(format!("{}.json", Self::safe_id(id)))
464    }
465
466    async fn save_index(&self) -> anyhow::Result<()> {
467        let index = self.index.read().await;
468        let json = serde_json::to_string(&*index).context("Failed to serialize memory index")?;
469        drop(index);
470        let tmp = self.index_path.with_extension("json.tmp");
471        tokio::fs::write(&tmp, json.as_bytes())
472            .await
473            .context("Failed to write memory index temp file")?;
474        tokio::fs::rename(&tmp, &self.index_path)
475            .await
476            .context("Failed to rename memory index")?;
477        Ok(())
478    }
479
480    async fn save_item(&self, item: &MemoryItem) -> anyhow::Result<()> {
481        let path = self.item_path(&item.id);
482        let json = serde_json::to_string_pretty(item)
483            .with_context(|| format!("Failed to serialize memory item: {}", item.id))?;
484        let tmp = path.with_extension("json.tmp");
485        tokio::fs::write(&tmp, json.as_bytes())
486            .await
487            .with_context(|| format!("Failed to write memory item: {}", item.id))?;
488        tokio::fs::rename(&tmp, &path)
489            .await
490            .with_context(|| format!("Failed to rename memory item: {}", item.id))?;
491        Ok(())
492    }
493
494    /// Rebuild the index from item files on disk (useful for corruption recovery).
495    pub async fn rebuild_index(&self) -> anyhow::Result<usize> {
496        let mut entries = tokio::fs::read_dir(&self.items_dir).await?;
497        let mut new_index = Vec::new();
498        while let Some(entry) = entries.next_entry().await? {
499            let path = entry.path();
500            if path.extension().is_some_and(|ext| ext == "json") {
501                if let Ok(data) = tokio::fs::read_to_string(&path).await {
502                    if let Ok(item) = serde_json::from_str::<MemoryItem>(&data) {
503                        new_index.push(IndexEntry::from(&item));
504                    }
505                }
506            }
507        }
508        let count = new_index.len();
509        *self.index.write().await = new_index;
510        self.save_index().await?;
511        Ok(count)
512    }
513}
514
515#[async_trait::async_trait]
516impl MemoryStore for FileMemoryStore {
517    async fn store(&self, item: MemoryItem) -> anyhow::Result<()> {
518        let mut item = item;
519        item.id = Self::safe_id(&item.id);
520        self.save_item(&item).await?;
521        let entry = IndexEntry::from(&item);
522        let mut index = self.index.write().await;
523        if let Some(pos) = index.iter().position(|e| e.id == item.id) {
524            index[pos] = entry;
525        } else {
526            index.push(entry);
527        }
528        drop(index);
529        self.save_index().await
530    }
531
532    async fn retrieve(&self, id: &str) -> anyhow::Result<Option<MemoryItem>> {
533        let path = self.item_path(id);
534        if !path.exists() {
535            return Ok(None);
536        }
537        let data = tokio::fs::read_to_string(&path).await?;
538        let mut item: MemoryItem = serde_json::from_str(&data)?;
539        item.content_lower = item.content.to_lowercase();
540        Ok(Some(item))
541    }
542
543    async fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
544        let query_lower = query.to_lowercase();
545        let index = self.index.read().await;
546        let now = Utc::now();
547        let config = RelevanceConfig::default();
548        let mut matches: Vec<&IndexEntry> = index
549            .iter()
550            .filter(|e| e.content_lower.contains(&query_lower))
551            .collect();
552        matches.sort_by(|a, b| {
553            index_score(a, now, &config)
554                .partial_cmp(&index_score(b, now, &config))
555                .unwrap_or(std::cmp::Ordering::Equal)
556                .reverse()
557        });
558        let ids: Vec<String> = matches.iter().take(limit).map(|e| e.id.clone()).collect();
559        drop(index);
560        let mut items = Vec::with_capacity(ids.len());
561        for id in ids {
562            if let Some(item) = self.retrieve(&id).await? {
563                items.push(item);
564            }
565        }
566        sort_by_relevance(&mut items);
567        Ok(items)
568    }
569
570    async fn search_by_tags(
571        &self,
572        tags: &[String],
573        limit: usize,
574    ) -> anyhow::Result<Vec<MemoryItem>> {
575        let index = self.index.read().await;
576        let now = Utc::now();
577        let config = RelevanceConfig::default();
578        let mut matches: Vec<&IndexEntry> = index
579            .iter()
580            .filter(|e| tags.iter().any(|t| e.tags.contains(t)))
581            .collect();
582        matches.sort_by(|a, b| {
583            index_score(a, now, &config)
584                .partial_cmp(&index_score(b, now, &config))
585                .unwrap_or(std::cmp::Ordering::Equal)
586                .reverse()
587        });
588        let ids: Vec<String> = matches.iter().take(limit).map(|e| e.id.clone()).collect();
589        drop(index);
590        let mut items = Vec::with_capacity(ids.len());
591        for id in ids {
592            if let Some(item) = self.retrieve(&id).await? {
593                items.push(item);
594            }
595        }
596        sort_by_relevance(&mut items);
597        Ok(items)
598    }
599
600    async fn get_recent(&self, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
601        let index = self.index.read().await;
602        let mut sorted: Vec<&IndexEntry> = index.iter().collect();
603        sorted.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
604        let ids: Vec<String> = sorted.iter().take(limit).map(|e| e.id.clone()).collect();
605        drop(index);
606        let mut items = Vec::with_capacity(ids.len());
607        for id in ids {
608            if let Some(item) = self.retrieve(&id).await? {
609                items.push(item);
610            }
611        }
612        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
613        Ok(items)
614    }
615
616    async fn get_important(&self, threshold: f32, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
617        let index = self.index.read().await;
618        let mut matches: Vec<&IndexEntry> =
619            index.iter().filter(|e| e.importance >= threshold).collect();
620        matches.sort_by(|a, b| {
621            b.importance
622                .partial_cmp(&a.importance)
623                .unwrap_or(std::cmp::Ordering::Equal)
624        });
625        let ids: Vec<String> = matches.iter().take(limit).map(|e| e.id.clone()).collect();
626        drop(index);
627        let mut items = Vec::with_capacity(ids.len());
628        for id in ids {
629            if let Some(item) = self.retrieve(&id).await? {
630                items.push(item);
631            }
632        }
633        items.sort_by(|a, b| {
634            b.importance
635                .partial_cmp(&a.importance)
636                .unwrap_or(std::cmp::Ordering::Equal)
637        });
638        Ok(items)
639    }
640
641    async fn delete(&self, id: &str) -> anyhow::Result<()> {
642        let path = self.item_path(id);
643        if path.exists() {
644            tokio::fs::remove_file(&path).await?;
645        }
646        let mut index = self.index.write().await;
647        index.retain(|e| e.id != id);
648        drop(index);
649        self.save_index().await
650    }
651
652    async fn clear(&self) -> anyhow::Result<()> {
653        let mut entries = tokio::fs::read_dir(&self.items_dir).await?;
654        while let Some(entry) = entries.next_entry().await? {
655            let path = entry.path();
656            if path.extension().is_some_and(|ext| ext == "json") {
657                let _ = tokio::fs::remove_file(&path).await;
658            }
659        }
660        self.index.write().await.clear();
661        self.save_index().await
662    }
663
664    async fn count(&self) -> anyhow::Result<usize> {
665        Ok(self.index.read().await.len())
666    }
667
668    async fn prune(&self, policy: &PrunePolicy) -> anyhow::Result<usize> {
669        let now = Utc::now();
670        let cutoff = now - chrono::Duration::days(policy.max_age_days as i64);
671        let min_importance = policy.min_importance_to_keep;
672
673        // Phase 1: collect IDs that are old AND below the importance threshold.
674        let phase1_ids: Vec<String> = {
675            let index = self.index.read().await;
676            index
677                .iter()
678                .filter(|e| e.importance < min_importance && e.timestamp < cutoff)
679                .map(|e| e.id.clone())
680                .collect()
681        };
682        let mut deleted = phase1_ids.len();
683        for id in &phase1_ids {
684            self.delete(id).await?;
685        }
686
687        // Phase 2: enforce max_items cap by removing lowest-relevance items.
688        if policy.max_items > 0 {
689            let config = RelevanceConfig::default();
690            let phase2_ids: Vec<String> = {
691                let index = self.index.read().await;
692                if index.len() <= policy.max_items {
693                    Vec::new()
694                } else {
695                    let mut entries: Vec<&IndexEntry> = index.iter().collect();
696                    entries.sort_by(|a, b| {
697                        index_score(b, now, &config)
698                            .partial_cmp(&index_score(a, now, &config))
699                            .unwrap_or(std::cmp::Ordering::Equal)
700                    });
701                    entries[policy.max_items..]
702                        .iter()
703                        .map(|e| e.id.clone())
704                        .collect()
705                }
706            };
707            deleted += phase2_ids.len();
708            for id in &phase2_ids {
709                self.delete(id).await?;
710            }
711        }
712
713        Ok(deleted)
714    }
715}
716
717// ============================================================================
718// Tests
719// ============================================================================
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    // MemoryItem tests
726
727    #[test]
728    fn test_memory_item_creation() {
729        let item = MemoryItem::new("Test memory")
730            .with_importance(0.8)
731            .with_tag("test")
732            .with_type(MemoryType::Semantic);
733        assert_eq!(item.content, "Test memory");
734        assert_eq!(item.importance, 0.8);
735        assert_eq!(item.tags, vec!["test"]);
736        assert_eq!(item.memory_type, MemoryType::Semantic);
737    }
738
739    #[test]
740    fn test_memory_item_importance_clamped() {
741        assert_eq!(MemoryItem::new("x").with_importance(1.5).importance, 1.0);
742        assert_eq!(MemoryItem::new("x").with_importance(-0.5).importance, 0.0);
743    }
744
745    #[test]
746    fn test_memory_item_record_access() {
747        let mut item = MemoryItem::new("test");
748        assert_eq!(item.access_count, 0);
749        item.record_access();
750        assert_eq!(item.access_count, 1);
751        assert!(item.last_accessed.is_some());
752    }
753
754    #[test]
755    fn test_memory_item_default_type_is_episodic() {
756        assert_eq!(MemoryItem::new("test").memory_type, MemoryType::Episodic);
757    }
758
759    #[test]
760    fn test_memory_item_all_types() {
761        assert_eq!(
762            MemoryItem::new("e")
763                .with_type(MemoryType::Episodic)
764                .memory_type,
765            MemoryType::Episodic
766        );
767        assert_eq!(
768            MemoryItem::new("s")
769                .with_type(MemoryType::Semantic)
770                .memory_type,
771            MemoryType::Semantic
772        );
773        assert_eq!(
774            MemoryItem::new("p")
775                .with_type(MemoryType::Procedural)
776                .memory_type,
777            MemoryType::Procedural
778        );
779        assert_eq!(
780            MemoryItem::new("w")
781                .with_type(MemoryType::Working)
782                .memory_type,
783            MemoryType::Working
784        );
785    }
786
787    // relevance_score_at tests
788
789    #[test]
790    fn test_relevance_score_uses_config() {
791        let item = MemoryItem::new("test").with_importance(1.0);
792        let now = Utc::now();
793
794        // High importance weight → score dominated by importance
795        let config_importance = RelevanceConfig {
796            decay_days: 30.0,
797            importance_weight: 0.9,
798            recency_weight: 0.1,
799        };
800        let score = item.relevance_score_at(now, &config_importance);
801        assert!(score > 0.95, "score was {score}");
802
803        // Short decay → recent item still scores well
804        let config_fast_decay = RelevanceConfig {
805            decay_days: 1.0,
806            importance_weight: 0.7,
807            recency_weight: 0.3,
808        };
809        let score2 = item.relevance_score_at(now, &config_fast_decay);
810        assert!(score2 > 0.9, "score was {score2}");
811    }
812
813    #[test]
814    fn test_relevance_score_decays_with_age() {
815        let mut old_item = MemoryItem::new("old").with_importance(0.5);
816        old_item.timestamp = Utc::now() - chrono::Duration::days(60);
817        let config = RelevanceConfig::default(); // 30-day half-life
818        let score = old_item.relevance_score_at(Utc::now(), &config);
819        // After 60 days (2 half-lives), decay ≈ exp(-2) ≈ 0.135
820        // score ≈ 0.5*0.7 + 0.135*0.3 ≈ 0.39
821        assert!(score < 0.45, "score was {score}");
822    }
823
824    #[test]
825    fn test_relevance_score_default_uses_default_config() {
826        let item = MemoryItem::new("test").with_importance(0.9);
827        let score = item.relevance_score();
828        assert!(score > 0.6);
829    }
830
831    // RelevanceConfig tests
832
833    #[test]
834    fn test_relevance_config_defaults() {
835        let c = RelevanceConfig::default();
836        assert_eq!(c.decay_days, 30.0);
837        assert_eq!(c.importance_weight, 0.7);
838        assert_eq!(c.recency_weight, 0.3);
839    }
840
841    // InMemoryStore tests
842
843    #[tokio::test]
844    async fn test_in_memory_store_retrieve() {
845        let store = InMemoryStore::new();
846        let item = MemoryItem::new("hello").with_tag("test");
847        store.store(item.clone()).await.unwrap();
848        let r = store.retrieve(&item.id).await.unwrap();
849        assert!(r.is_some());
850        assert_eq!(r.unwrap().content, "hello");
851    }
852
853    #[tokio::test]
854    async fn test_in_memory_store_retrieve_nonexistent() {
855        let store = InMemoryStore::new();
856        assert!(store.retrieve("nope").await.unwrap().is_none());
857    }
858
859    #[tokio::test]
860    async fn test_in_memory_store_upsert() {
861        let store = InMemoryStore::new();
862        let mut item = MemoryItem::new("original");
863        let id = item.id.clone();
864        store.store(item.clone()).await.unwrap();
865        item.content = "updated".to_string();
866        item.content_lower = "updated".to_string();
867        store.store(item).await.unwrap();
868        assert_eq!(store.count().await.unwrap(), 1);
869        assert_eq!(
870            store.retrieve(&id).await.unwrap().unwrap().content,
871            "updated"
872        );
873    }
874
875    #[tokio::test]
876    async fn test_in_memory_store_search_and_tags() {
877        let store = InMemoryStore::new();
878        store
879            .store(MemoryItem::new("create file").with_tag("file"))
880            .await
881            .unwrap();
882        store
883            .store(MemoryItem::new("delete file").with_tag("file"))
884            .await
885            .unwrap();
886        store
887            .store(MemoryItem::new("create dir").with_tag("dir"))
888            .await
889            .unwrap();
890        assert_eq!(store.search("create", 10).await.unwrap().len(), 2);
891        assert_eq!(
892            store
893                .search_by_tags(&["file".to_string()], 10)
894                .await
895                .unwrap()
896                .len(),
897            2
898        );
899    }
900
901    #[tokio::test]
902    async fn test_in_memory_store_search_relevance_order() {
903        let store = InMemoryStore::new();
904        store
905            .store(MemoryItem::new("rust tip").with_importance(0.3))
906            .await
907            .unwrap();
908        store
909            .store(MemoryItem::new("rust trick").with_importance(0.9))
910            .await
911            .unwrap();
912        let results = store.search("rust", 10).await.unwrap();
913        assert_eq!(results.len(), 2);
914        assert!(results[0].importance >= results[1].importance);
915    }
916
917    #[tokio::test]
918    async fn test_in_memory_store_delete_and_clear() {
919        let store = InMemoryStore::new();
920        let item = MemoryItem::new("to delete");
921        let id = item.id.clone();
922        store.store(item).await.unwrap();
923        store.delete(&id).await.unwrap();
924        assert_eq!(store.count().await.unwrap(), 0);
925
926        for i in 0..3 {
927            store
928                .store(MemoryItem::new(format!("item {i}")))
929                .await
930                .unwrap();
931        }
932        store.clear().await.unwrap();
933        assert_eq!(store.count().await.unwrap(), 0);
934    }
935
936    #[tokio::test]
937    async fn test_in_memory_store_get_recent() {
938        let store = InMemoryStore::new();
939        for i in 0..5 {
940            let mut item = MemoryItem::new(format!("item {i}"));
941            item.timestamp = Utc::now() + chrono::Duration::seconds(i as i64);
942            store.store(item).await.unwrap();
943        }
944        let recent = store.get_recent(3).await.unwrap();
945        assert_eq!(recent.len(), 3);
946        assert!(recent[0].timestamp >= recent[1].timestamp);
947    }
948
949    #[tokio::test]
950    async fn test_in_memory_store_get_important() {
951        let store = InMemoryStore::new();
952        store
953            .store(MemoryItem::new("low").with_importance(0.2))
954            .await
955            .unwrap();
956        store
957            .store(MemoryItem::new("high").with_importance(0.9))
958            .await
959            .unwrap();
960        store
961            .store(MemoryItem::new("medium").with_importance(0.5))
962            .await
963            .unwrap();
964        let results = store.get_important(0.7, 10).await.unwrap();
965        assert_eq!(results.len(), 1);
966        assert_eq!(results[0].content, "high");
967    }
968
969    #[test]
970    fn test_in_memory_store_default() {
971        let _store: InMemoryStore = InMemoryStore::default();
972    }
973
974    // PrunePolicy / prune() tests
975
976    #[test]
977    fn test_prune_policy_defaults() {
978        let p = PrunePolicy::default();
979        assert_eq!(p.max_age_days, 90);
980        assert_eq!(p.min_importance_to_keep, 0.5);
981        assert_eq!(p.max_items, 0);
982    }
983
984    #[tokio::test]
985    async fn test_prune_removes_old_low_importance() {
986        let store = InMemoryStore::new();
987        let mut old_item = MemoryItem::new("stale memory").with_importance(0.2);
988        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
989        store.store(old_item).await.unwrap();
990
991        let policy = PrunePolicy {
992            max_age_days: 90,
993            min_importance_to_keep: 0.5,
994            max_items: 0,
995        };
996        let deleted = store.prune(&policy).await.unwrap();
997        assert_eq!(deleted, 1);
998        assert_eq!(store.count().await.unwrap(), 0);
999    }
1000
1001    #[tokio::test]
1002    async fn test_prune_keeps_high_importance() {
1003        let store = InMemoryStore::new();
1004        let mut old_item = MemoryItem::new("important memory").with_importance(0.9);
1005        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
1006        store.store(old_item).await.unwrap();
1007
1008        let policy = PrunePolicy {
1009            max_age_days: 90,
1010            min_importance_to_keep: 0.5,
1011            max_items: 0,
1012        };
1013        let deleted = store.prune(&policy).await.unwrap();
1014        assert_eq!(deleted, 0);
1015        assert_eq!(store.count().await.unwrap(), 1);
1016    }
1017
1018    #[tokio::test]
1019    async fn test_prune_max_items() {
1020        let store = InMemoryStore::new();
1021        for i in 0..10 {
1022            store
1023                .store(MemoryItem::new(format!("item {i}")).with_importance(i as f32 * 0.1))
1024                .await
1025                .unwrap();
1026        }
1027        let policy = PrunePolicy {
1028            max_age_days: 9999,
1029            min_importance_to_keep: 0.0,
1030            max_items: 5,
1031        };
1032        let deleted = store.prune(&policy).await.unwrap();
1033        assert_eq!(deleted, 5);
1034        assert_eq!(store.count().await.unwrap(), 5);
1035    }
1036
1037    #[tokio::test]
1038    async fn test_prune_keeps_recent_low_importance() {
1039        // A recent item with low importance should NOT be pruned (not old enough)
1040        let store = InMemoryStore::new();
1041        store
1042            .store(MemoryItem::new("fresh").with_importance(0.1))
1043            .await
1044            .unwrap();
1045
1046        let policy = PrunePolicy {
1047            max_age_days: 90,
1048            min_importance_to_keep: 0.5,
1049            max_items: 0,
1050        };
1051        let deleted = store.prune(&policy).await.unwrap();
1052        assert_eq!(deleted, 0);
1053        assert_eq!(store.count().await.unwrap(), 1);
1054    }
1055}
1056
1057#[cfg(test)]
1058mod file_memory_store_tests {
1059    use super::*;
1060    use tempfile::TempDir;
1061
1062    async fn setup() -> (TempDir, FileMemoryStore) {
1063        let dir = TempDir::new().unwrap();
1064        let store = FileMemoryStore::new(dir.path()).await.unwrap();
1065        (dir, store)
1066    }
1067
1068    #[tokio::test]
1069    async fn test_store_and_retrieve() {
1070        let (_dir, store) = setup().await;
1071        let item = MemoryItem::new("hello world");
1072        let id = item.id.clone();
1073        store.store(item).await.unwrap();
1074        let r = store.retrieve(&id).await.unwrap().unwrap();
1075        assert_eq!(r.content, "hello world");
1076    }
1077
1078    #[tokio::test]
1079    async fn test_retrieve_nonexistent() {
1080        let (_dir, store) = setup().await;
1081        assert!(store.retrieve("nonexistent").await.unwrap().is_none());
1082    }
1083
1084    #[tokio::test]
1085    async fn test_search_by_content() {
1086        let (_dir, store) = setup().await;
1087        store
1088            .store(MemoryItem::new("rust programming"))
1089            .await
1090            .unwrap();
1091        store
1092            .store(MemoryItem::new("python scripting"))
1093            .await
1094            .unwrap();
1095        store
1096            .store(MemoryItem::new("rust async patterns"))
1097            .await
1098            .unwrap();
1099        let results = store.search("rust", 10).await.unwrap();
1100        assert_eq!(results.len(), 2);
1101    }
1102
1103    #[tokio::test]
1104    async fn test_search_limit() {
1105        let (_dir, store) = setup().await;
1106        for i in 0..10 {
1107            store
1108                .store(MemoryItem::new(format!("item {i}")))
1109                .await
1110                .unwrap();
1111        }
1112        assert_eq!(store.search("item", 3).await.unwrap().len(), 3);
1113    }
1114
1115    #[tokio::test]
1116    async fn test_search_by_tags() {
1117        let (_dir, store) = setup().await;
1118        store
1119            .store(MemoryItem::new("one").with_tags(vec!["rust".into(), "async".into()]))
1120            .await
1121            .unwrap();
1122        store
1123            .store(MemoryItem::new("two").with_tags(vec!["python".into()]))
1124            .await
1125            .unwrap();
1126        store
1127            .store(MemoryItem::new("three").with_tags(vec!["rust".into()]))
1128            .await
1129            .unwrap();
1130        assert_eq!(
1131            store
1132                .search_by_tags(&["rust".to_string()], 10)
1133                .await
1134                .unwrap()
1135                .len(),
1136            2
1137        );
1138    }
1139
1140    #[tokio::test]
1141    async fn test_get_recent_ordered() {
1142        let (_dir, store) = setup().await;
1143        for i in 0..5 {
1144            let mut item = MemoryItem::new(format!("item {i}"));
1145            item.timestamp = Utc::now() + chrono::Duration::seconds(i as i64);
1146            store.store(item).await.unwrap();
1147        }
1148        let results = store.get_recent(3).await.unwrap();
1149        assert_eq!(results.len(), 3);
1150        assert!(results[0].timestamp >= results[1].timestamp);
1151    }
1152
1153    #[tokio::test]
1154    async fn test_get_important() {
1155        let (_dir, store) = setup().await;
1156        store
1157            .store(MemoryItem::new("low").with_importance(0.1))
1158            .await
1159            .unwrap();
1160        store
1161            .store(MemoryItem::new("high").with_importance(0.9))
1162            .await
1163            .unwrap();
1164        store
1165            .store(MemoryItem::new("medium").with_importance(0.5))
1166            .await
1167            .unwrap();
1168        let results = store.get_important(0.0, 2).await.unwrap();
1169        assert_eq!(results.len(), 2);
1170        assert!(results[0].importance >= results[1].importance);
1171    }
1172
1173    #[tokio::test]
1174    async fn test_delete() {
1175        let (_dir, store) = setup().await;
1176        let item = MemoryItem::new("to delete");
1177        let id = item.id.clone();
1178        store.store(item).await.unwrap();
1179        store.delete(&id).await.unwrap();
1180        assert_eq!(store.count().await.unwrap(), 0);
1181        assert!(store.retrieve(&id).await.unwrap().is_none());
1182    }
1183
1184    #[tokio::test]
1185    async fn test_delete_nonexistent() {
1186        let (_dir, store) = setup().await;
1187        store.delete("nonexistent").await.unwrap();
1188    }
1189
1190    #[tokio::test]
1191    async fn test_clear() {
1192        let (_dir, store) = setup().await;
1193        for i in 0..5 {
1194            store
1195                .store(MemoryItem::new(format!("item {i}")))
1196                .await
1197                .unwrap();
1198        }
1199        store.clear().await.unwrap();
1200        assert_eq!(store.count().await.unwrap(), 0);
1201    }
1202
1203    #[tokio::test]
1204    async fn test_persistence_across_instances() {
1205        let dir = TempDir::new().unwrap();
1206        {
1207            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1208            store
1209                .store(MemoryItem::new("persistent data").with_tags(vec!["test".into()]))
1210                .await
1211                .unwrap();
1212        }
1213        {
1214            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1215            assert_eq!(store.count().await.unwrap(), 1);
1216            assert_eq!(store.search("persistent", 10).await.unwrap().len(), 1);
1217        }
1218    }
1219
1220    #[tokio::test]
1221    async fn test_rebuild_index() {
1222        let dir = TempDir::new().unwrap();
1223        {
1224            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1225            store.store(MemoryItem::new("alpha")).await.unwrap();
1226            store.store(MemoryItem::new("beta")).await.unwrap();
1227        }
1228        tokio::fs::remove_file(dir.path().join("index.json"))
1229            .await
1230            .unwrap();
1231        {
1232            let store = FileMemoryStore::new(dir.path()).await.unwrap();
1233            assert_eq!(store.count().await.unwrap(), 0);
1234            store.rebuild_index().await.unwrap();
1235            assert_eq!(store.count().await.unwrap(), 2);
1236        }
1237    }
1238
1239    #[tokio::test]
1240    async fn test_path_traversal_prevention() {
1241        let (_dir, store) = setup().await;
1242        let mut item = MemoryItem::new("sneaky");
1243        item.id = "../../../etc/passwd".to_string();
1244        store.store(item).await.unwrap();
1245        let results = store.search("sneaky", 10).await.unwrap();
1246        assert_eq!(results.len(), 1);
1247        assert!(!results[0].id.contains('/'));
1248        assert!(!results[0].id.contains(".."));
1249    }
1250
1251    #[tokio::test]
1252    async fn test_importance_threshold() {
1253        let (_dir, store) = setup().await;
1254        store
1255            .store(MemoryItem::new("low").with_importance(0.2))
1256            .await
1257            .unwrap();
1258        store
1259            .store(MemoryItem::new("high").with_importance(0.8))
1260            .await
1261            .unwrap();
1262        let results = store.get_important(0.5, 10).await.unwrap();
1263        assert_eq!(results.len(), 1);
1264        assert_eq!(results[0].content, "high");
1265    }
1266
1267    #[tokio::test]
1268    async fn test_file_prune_removes_old_low_importance() {
1269        let (_dir, store) = setup().await;
1270        let mut old_item = MemoryItem::new("stale").with_importance(0.2);
1271        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
1272        store.store(old_item).await.unwrap();
1273
1274        let policy = PrunePolicy {
1275            max_age_days: 90,
1276            min_importance_to_keep: 0.5,
1277            max_items: 0,
1278        };
1279        let deleted = store.prune(&policy).await.unwrap();
1280        assert_eq!(deleted, 1);
1281        assert_eq!(store.count().await.unwrap(), 0);
1282    }
1283
1284    #[tokio::test]
1285    async fn test_file_prune_keeps_high_importance() {
1286        let (_dir, store) = setup().await;
1287        let mut old_item = MemoryItem::new("important").with_importance(0.9);
1288        old_item.timestamp = Utc::now() - chrono::Duration::days(100);
1289        store.store(old_item).await.unwrap();
1290
1291        let policy = PrunePolicy {
1292            max_age_days: 90,
1293            min_importance_to_keep: 0.5,
1294            max_items: 0,
1295        };
1296        let deleted = store.prune(&policy).await.unwrap();
1297        assert_eq!(deleted, 0);
1298        assert_eq!(store.count().await.unwrap(), 1);
1299    }
1300
1301    #[tokio::test]
1302    async fn test_file_prune_max_items() {
1303        let (_dir, store) = setup().await;
1304        for i in 0..10 {
1305            store
1306                .store(MemoryItem::new(format!("item {i}")).with_importance(i as f32 * 0.1))
1307                .await
1308                .unwrap();
1309        }
1310        let policy = PrunePolicy {
1311            max_age_days: 9999,
1312            min_importance_to_keep: 0.0,
1313            max_items: 5,
1314        };
1315        let deleted = store.prune(&policy).await.unwrap();
1316        assert_eq!(deleted, 5);
1317        assert_eq!(store.count().await.unwrap(), 5);
1318    }
1319}