Skip to main content

velesdb_core/agent/
memory.rs

1//! `AgentMemory` - Unified memory interface for AI agents (EPIC-010)
2//!
3//! Provides three memory subsystems for AI agents:
4//! - **`SemanticMemory`**: Long-term knowledge facts with vector similarity search
5//! - **`EpisodicMemory`**: Event timeline with temporal and similarity queries
6//! - **`ProceduralMemory`**: Learned patterns with confidence scoring
7//!
8//! # Enhanced Features
9//!
10//! - **TTL/Eviction**: Automatic expiration and memory consolidation
11//! - **Snapshots**: Versioned state persistence and rollback
12//! - **Temporal Index**: Efficient O(log N) time-based queries
13//! - **Adaptive Reinforcement**: Extensible confidence update strategies
14
15// Reason: Numeric casts in agent memory are intentional:
16// - u64 <-> i64 casts for timestamps (SystemTime uses u64, DB schema uses i64)
17// - Values are always positive (elapsed time) and bounded by reasonable ranges
18// - Casts verified by temporal index tests and snapshot functionality
19#![allow(clippy::cast_possible_wrap)]
20#![allow(clippy::cast_sign_loss)]
21
22use crate::Database;
23use std::sync::Arc;
24
25pub use super::episodic_memory::EpisodicMemory;
26pub use super::error::AgentMemoryError;
27pub use super::procedural_memory::{ProceduralMemory, ProcedureMatch};
28pub use super::semantic_memory::SemanticMemory;
29pub use super::snapshot::{MemoryState, SnapshotManager};
30pub use super::temporal_index::TemporalIndex;
31pub use super::ttl::{EvictionConfig, ExpireResult, MemoryKind, MemoryTtl};
32
33/// Default embedding dimension for memory collections.
34pub const DEFAULT_DIMENSION: usize = 384;
35
36/// Unified memory interface for AI agents.
37///
38/// Provides access to three memory subsystems:
39/// - `semantic`: Long-term knowledge (vector storage; graph linkage planned)
40/// - `episodic`: Event timeline with temporal context
41/// - `procedural`: Learned patterns and action sequences
42///
43/// # Enhanced Features
44///
45/// - TTL management for automatic expiration
46/// - Snapshot/restore for state persistence
47/// - Temporal indexing for efficient time-based queries
48/// - Configurable eviction policies
49pub struct AgentMemory {
50    db: Arc<Database>,
51    semantic: SemanticMemory,
52    episodic: EpisodicMemory,
53    procedural: ProceduralMemory,
54    ttl: Arc<MemoryTtl>,
55    eviction_config: EvictionConfig,
56    snapshot_manager: Option<SnapshotManager>,
57}
58
59impl AgentMemory {
60    /// Creates a new `AgentMemory` instance from a `Database`.
61    ///
62    /// Initializes or connects to the three memory subsystem collections:
63    /// - `_semantic_memory`: For knowledge facts
64    /// - `_episodic_memory`: For event timeline
65    /// - `_procedural_memory`: For learned patterns
66    ///
67    /// Uses the default embedding dimension (384).
68    ///
69    /// # Errors
70    ///
71    /// Returns an error when one of the underlying memory subsystems cannot be initialized.
72    pub fn new(db: Arc<Database>) -> Result<Self, AgentMemoryError> {
73        Self::with_dimension(db, DEFAULT_DIMENSION)
74    }
75
76    /// Creates a new `AgentMemory` with a custom embedding dimension.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error when one of the underlying memory subsystems cannot be initialized.
81    pub fn with_dimension(db: Arc<Database>, dimension: usize) -> Result<Self, AgentMemoryError> {
82        let ttl = Arc::new(MemoryTtl::new());
83
84        let semantic = SemanticMemory::new(Arc::clone(&db), dimension, Arc::clone(&ttl))?;
85        let episodic = EpisodicMemory::new(
86            Arc::clone(&db),
87            dimension,
88            Arc::clone(&ttl),
89            Arc::new(TemporalIndex::new()),
90        )?;
91        let procedural = ProceduralMemory::new(Arc::clone(&db), dimension, Arc::clone(&ttl))?;
92
93        Ok(Self {
94            db,
95            semantic,
96            episodic,
97            procedural,
98            ttl,
99            eviction_config: EvictionConfig::default(),
100            snapshot_manager: None,
101        })
102    }
103
104    /// Configures eviction policies for automatic memory cleanup.
105    #[must_use]
106    pub fn with_eviction_config(mut self, config: EvictionConfig) -> Self {
107        self.eviction_config = config;
108        self
109    }
110
111    /// Enables snapshot management with a storage directory.
112    ///
113    /// # Arguments
114    ///
115    /// * `snapshot_dir` - Directory path for storing snapshots
116    /// * `max_snapshots` - Maximum number of snapshots to retain
117    #[must_use]
118    pub fn with_snapshots(mut self, snapshot_dir: &str, max_snapshots: usize) -> Self {
119        self.snapshot_manager = Some(SnapshotManager::new(snapshot_dir, max_snapshots));
120        self
121    }
122
123    /// Returns a reference to the semantic memory subsystem.
124    #[must_use]
125    pub fn semantic(&self) -> &SemanticMemory {
126        &self.semantic
127    }
128
129    /// Returns a reference to the episodic memory subsystem.
130    #[must_use]
131    pub fn episodic(&self) -> &EpisodicMemory {
132        &self.episodic
133    }
134
135    /// Returns a reference to the procedural memory subsystem.
136    #[must_use]
137    pub fn procedural(&self) -> &ProceduralMemory {
138        &self.procedural
139    }
140
141    /// Sets TTL for a semantic memory entry (in-memory only; lost on restart).
142    ///
143    /// Use [`Self::set_semantic_ttl_durable`] to persist the expiry.
144    pub fn set_semantic_ttl(&self, id: u64, ttl_seconds: u64) {
145        self.ttl.set_ttl(MemoryKind::Semantic, id, ttl_seconds);
146    }
147
148    /// Sets TTL for an episodic memory entry (in-memory only; lost on restart).
149    ///
150    /// Use [`Self::set_episodic_ttl_durable`] to persist the expiry.
151    pub fn set_episodic_ttl(&self, id: u64, ttl_seconds: u64) {
152        self.ttl.set_ttl(MemoryKind::Episodic, id, ttl_seconds);
153    }
154
155    /// Sets TTL for a procedural memory entry (in-memory only; lost on restart).
156    ///
157    /// Use [`Self::set_procedural_ttl_durable`] to persist the expiry.
158    pub fn set_procedural_ttl(&self, id: u64, ttl_seconds: u64) {
159        self.ttl.set_ttl(MemoryKind::Procedural, id, ttl_seconds);
160    }
161
162    /// Durably sets the TTL of an existing semantic fact: the expiry is
163    /// persisted to the reserved `_veles_expires_at` payload field and
164    /// survives a restart.
165    ///
166    /// # Errors
167    ///
168    /// Returns `NotFound` when no fact with `id` exists, or
169    /// `CollectionError` when persistence fails.
170    pub fn set_semantic_ttl_durable(
171        &self,
172        id: u64,
173        ttl_seconds: u64,
174    ) -> Result<(), AgentMemoryError> {
175        self.semantic.set_ttl_durable(id, ttl_seconds)
176    }
177
178    /// Durably sets the TTL of an existing episodic event: the expiry is
179    /// persisted to the reserved `_veles_expires_at` payload field and
180    /// survives a restart.
181    ///
182    /// # Errors
183    ///
184    /// Returns `NotFound` when no event with `id` exists, or
185    /// `CollectionError` when persistence fails.
186    pub fn set_episodic_ttl_durable(
187        &self,
188        id: u64,
189        ttl_seconds: u64,
190    ) -> Result<(), AgentMemoryError> {
191        self.episodic.set_ttl_durable(id, ttl_seconds)
192    }
193
194    /// Durably sets the TTL of an existing procedure: the expiry is
195    /// persisted to the reserved `_veles_expires_at` payload field and
196    /// survives a restart.
197    ///
198    /// # Errors
199    ///
200    /// Returns `NotFound` when no procedure with `id` exists, or
201    /// `CollectionError` when persistence fails.
202    pub fn set_procedural_ttl_durable(
203        &self,
204        id: u64,
205        ttl_seconds: u64,
206    ) -> Result<(), AgentMemoryError> {
207        self.procedural.set_ttl_durable(id, ttl_seconds)
208    }
209
210    /// Performs automatic expiration of entries that have exceeded their TTL.
211    ///
212    /// This method should be called periodically to clean up expired entries.
213    /// It also handles consolidation of old episodic memories to semantic memory
214    /// based on the configured eviction policy.
215    ///
216    /// # Returns
217    ///
218    /// Statistics about the expiration operation.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error when consolidation operations fail.
223    pub fn auto_expire(&self) -> Result<ExpireResult, AgentMemoryError> {
224        // Read expired keys WITHOUT dropping their TTL entries yet. The entry is
225        // removed (via the subsystem `delete`, which calls `ttl.remove`) only
226        // after the point is actually deleted, so a failed delete leaves the TTL
227        // entry intact and the id is retried on the next `auto_expire`. This
228        // preserves the expiry invariant: a tracked-expired id is never forgotten
229        // while its point still exists.
230        //
231        // Each key carries its owning `MemoryKind`, so an expired semantic id is
232        // only ever deleted from semantic memory — it can never clobber a live
233        // row that happens to share the numeric id in another subsystem.
234        let expired_keys = self.ttl.get_expired();
235        let mut result = ExpireResult::default();
236
237        for &(kind, id) in &expired_keys {
238            self.expire_one(kind, id, &mut result)?;
239        }
240
241        if self.eviction_config.consolidation_age_threshold > 0 {
242            let now = std::time::SystemTime::now()
243                .duration_since(std::time::UNIX_EPOCH)
244                .map_or(0, |d| d.as_secs() as i64);
245            let cutoff = now - self.eviction_config.consolidation_age_threshold as i64;
246            let outcome = self.consolidate_old_episodes(cutoff)?;
247            result.episodic_consolidated = outcome.consolidated;
248            result.consolidation_truncated = outcome.truncated;
249        }
250
251        result.procedural_evicted =
252            self.evict_low_confidence_procedures(self.eviction_config.min_confidence_threshold)?;
253
254        Ok(result)
255    }
256
257    /// Deletes a single expired entry from the subsystem that owns it and
258    /// increments the matching counter only on a real deletion.
259    fn expire_one(
260        &self,
261        kind: MemoryKind,
262        id: u64,
263        result: &mut ExpireResult,
264    ) -> Result<(), AgentMemoryError> {
265        match kind {
266            MemoryKind::Semantic => {
267                self.semantic.delete(id)?;
268                result.semantic_expired += 1;
269            }
270            MemoryKind::Episodic => {
271                self.episodic.delete(id)?;
272                result.episodic_expired += 1;
273            }
274            MemoryKind::Procedural => {
275                self.procedural.delete(id)?;
276                result.procedural_expired += 1;
277            }
278        }
279        Ok(())
280    }
281
282    /// Evicts procedures with confidence below the threshold.
283    ///
284    /// # Arguments
285    ///
286    /// * `min_confidence` - Minimum confidence threshold (0.0 - 1.0)
287    ///
288    /// # Returns
289    ///
290    /// Number of procedures evicted.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error when listing or deleting procedures fails.
295    pub fn evict_low_confidence_procedures(
296        &self,
297        min_confidence: f32,
298    ) -> Result<usize, AgentMemoryError> {
299        let all_procedures = self.procedural.list_all()?;
300        let mut evicted = 0;
301
302        for proc in all_procedures {
303            if proc.confidence < min_confidence {
304                self.procedural.delete(proc.id)?;
305                evicted += 1;
306            }
307        }
308
309        Ok(evicted)
310    }
311
312    /// Returns the snapshot manager, or an error if not configured.
313    ///
314    /// RF-DEDUP: Eliminates the repeated `ok_or_else(|| SnapshotError(...))` pattern
315    /// across `snapshot`, `load_latest_snapshot`, `load_snapshot_version`, and
316    /// `list_snapshot_versions`.
317    fn require_snapshot_manager(&self) -> Result<&SnapshotManager, AgentMemoryError> {
318        self.snapshot_manager.as_ref().ok_or_else(|| {
319            AgentMemoryError::SnapshotError("Snapshot manager not configured".to_string())
320        })
321    }
322
323    /// Creates a snapshot of the current memory state.
324    ///
325    /// # Returns
326    ///
327    /// The version number of the created snapshot.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error when snapshot manager is not configured or snapshot persistence fails.
332    pub fn snapshot(&self) -> Result<u64, AgentMemoryError> {
333        let manager = self.require_snapshot_manager()?;
334
335        let state = MemoryState {
336            semantic: self.semantic.serialize()?,
337            episodic: self.episodic.serialize()?,
338            procedural: self.procedural.serialize()?,
339            ttl: self.ttl.serialize(),
340        };
341
342        Ok(manager.create_versioned_snapshot(&state)?)
343    }
344
345    /// Loads the most recent snapshot.
346    ///
347    /// # Returns
348    ///
349    /// The version number of the loaded snapshot.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error when snapshot manager is not configured, loading fails,
354    /// or state restoration fails.
355    pub fn load_latest_snapshot(&self) -> Result<u64, AgentMemoryError> {
356        let manager = self.require_snapshot_manager()?;
357
358        let (version, state) = manager.load_latest()?;
359        self.restore_state(&state)?;
360        Ok(version)
361    }
362
363    /// Loads a specific snapshot version.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error when snapshot manager is not configured, loading fails,
368    /// or state restoration fails.
369    pub fn load_snapshot_version(&self, version: u64) -> Result<(), AgentMemoryError> {
370        let manager = self.require_snapshot_manager()?;
371
372        let state = manager.load_version(version)?;
373        self.restore_state(&state)?;
374        Ok(())
375    }
376
377    /// Lists all available snapshot versions.
378    ///
379    /// # Errors
380    ///
381    /// Returns an error when snapshot manager is not configured or listing fails.
382    pub fn list_snapshot_versions(&self) -> Result<Vec<u64>, AgentMemoryError> {
383        let manager = self.require_snapshot_manager()?;
384        Ok(manager.list_versions()?)
385    }
386
387    /// Executes a `VelesQL` query against the semantic memory collection.
388    ///
389    /// Delegates to `Collection::execute_query_str` on the `_semantic_memory`
390    /// collection. Use standard `VelesQL` syntax including `WHERE vector NEAR $v`,
391    /// payload filters, `ORDER BY`, and `WITH` options. TTL-expired entries are
392    /// filtered from the results, matching the native query APIs.
393    ///
394    /// # Errors
395    ///
396    /// Returns an error if the collection is missing or the query fails.
397    pub fn query_semantic(
398        &self,
399        sql: &str,
400        params: &std::collections::HashMap<String, serde_json::Value>,
401    ) -> Result<Vec<crate::SearchResult>, AgentMemoryError> {
402        super::memory_helpers::execute_velesql(
403            &self.db,
404            self.semantic.collection_name(),
405            sql,
406            params,
407            &self.ttl,
408            MemoryKind::Semantic,
409        )
410    }
411
412    /// Executes a `VelesQL` query against the episodic memory collection.
413    ///
414    /// Delegates to `Collection::execute_query_str` on the `_episodic_memory`
415    /// collection. Supports payload field filters like `WHERE timestamp > N`,
416    /// `ORDER BY timestamp DESC`, and similarity search via `NEAR`. TTL-expired
417    /// entries are filtered from the results, matching the native query APIs.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error if the collection is missing or the query fails.
422    pub fn query_episodic(
423        &self,
424        sql: &str,
425        params: &std::collections::HashMap<String, serde_json::Value>,
426    ) -> Result<Vec<crate::SearchResult>, AgentMemoryError> {
427        super::memory_helpers::execute_velesql(
428            &self.db,
429            self.episodic.collection_name(),
430            sql,
431            params,
432            &self.ttl,
433            MemoryKind::Episodic,
434        )
435    }
436
437    /// Executes a `VelesQL` query against the procedural memory collection.
438    ///
439    /// Delegates to `Collection::execute_query_str` on the `_procedural_memory`
440    /// collection. Supports payload field filters like `WHERE confidence > 0.7`,
441    /// `ORDER BY confidence DESC`, and scan queries. TTL-expired entries are
442    /// filtered from the results, matching the native query APIs.
443    ///
444    /// # Errors
445    ///
446    /// Returns an error if the collection is missing or the query fails.
447    pub fn query_procedural(
448        &self,
449        sql: &str,
450        params: &std::collections::HashMap<String, serde_json::Value>,
451    ) -> Result<Vec<crate::SearchResult>, AgentMemoryError> {
452        super::memory_helpers::execute_velesql(
453            &self.db,
454            self.procedural.collection_name(),
455            sql,
456            params,
457            &self.ttl,
458            MemoryKind::Procedural,
459        )
460    }
461
462    fn restore_state(&self, state: &MemoryState) -> Result<(), AgentMemoryError> {
463        self.semantic.deserialize(&state.semantic)?;
464        self.episodic.deserialize(&state.episodic)?;
465        self.procedural.deserialize(&state.procedural)?;
466
467        // An empty TTL section is legitimately empty state; anything else that
468        // fails to deserialize is a corrupt/incompatible snapshot and must not
469        // silently drop every TTL (which would make expiring entries immortal).
470        if state.ttl.is_empty() {
471            self.ttl.clear();
472        } else if let Some(ttl) = MemoryTtl::deserialize(&state.ttl) {
473            self.ttl.replace_from(&ttl);
474        } else {
475            return Err(AgentMemoryError::SnapshotError(
476                "TTL state failed to deserialize (corrupt or incompatible snapshot)".to_string(),
477            ));
478        }
479
480        Ok(())
481    }
482
483    /// Migrates episodic events older than `cutoff_timestamp` into semantic
484    /// memory, capped at `eviction_config.max_entries_per_cycle` per call.
485    ///
486    /// # Preconditions
487    ///
488    /// The episodic id is *not* reused verbatim as the semantic id: semantic
489    /// stores are upserts, so [`SemanticMemory::store_unique`] relocates the
490    /// fact to a fresh semantic id on collision. This guarantees consolidation
491    /// never clobbers an existing semantic fact even when the two subsystems
492    /// share a numeric id.
493    fn consolidate_old_episodes(
494        &self,
495        cutoff_timestamp: i64,
496    ) -> Result<ConsolidationOutcome, AgentMemoryError> {
497        let cap = self.eviction_config.max_entries_per_cycle;
498        // Fetch one past the cap so we can tell whether more old events remain
499        // than this cycle will process (the "truncated" signal).
500        let old_events = self
501            .episodic
502            .older_than(cutoff_timestamp, cap.saturating_add(1))?;
503        let truncated = old_events.len() > cap;
504        let mut consolidated = 0;
505
506        for (id, _description, _timestamp) in old_events.into_iter().take(cap) {
507            if let Some((description, _ts, embedding)) = self.episodic.get_with_embedding(id)? {
508                self.semantic.store_unique(id, &description, &embedding)?;
509                self.episodic.delete(id)?;
510                consolidated += 1;
511            }
512        }
513
514        Ok(ConsolidationOutcome {
515            consolidated,
516            truncated,
517        })
518    }
519}
520
521/// Outcome of one consolidation pass.
522struct ConsolidationOutcome {
523    /// Number of episodes migrated to semantic memory this cycle.
524    consolidated: usize,
525    /// `true` when more old episodes remained than the per-cycle cap allowed.
526    truncated: bool,
527}