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}