Skip to main content

mnemo_core/model/
memory.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct MemoryRecord {
6    pub id: Uuid,
7    pub agent_id: String,
8    pub content: String,
9    pub memory_type: MemoryType,
10    pub scope: Scope,
11    pub importance: f32,
12    pub tags: Vec<String>,
13    pub metadata: serde_json::Value,
14    pub embedding: Option<Vec<f32>>,
15    pub content_hash: Vec<u8>,
16    pub prev_hash: Option<Vec<u8>>,
17    pub source_type: SourceType,
18    pub source_id: Option<String>,
19    pub consolidation_state: ConsolidationState,
20    pub access_count: u64,
21    pub org_id: Option<String>,
22    pub thread_id: Option<String>,
23    pub created_at: String,
24    pub updated_at: String,
25    pub last_accessed_at: Option<String>,
26    pub expires_at: Option<String>,
27    pub deleted_at: Option<String>,
28    pub decay_rate: Option<f32>,
29    pub created_by: Option<String>,
30    pub version: u32,
31    pub prev_version_id: Option<uuid::Uuid>,
32    pub quarantined: bool,
33    pub quarantine_reason: Option<String>,
34    pub decay_function: Option<String>,
35}
36
37impl MemoryRecord {
38    /// Create a new MemoryRecord with sensible defaults.
39    /// Only `agent_id` and `content` are required; all other fields use defaults.
40    pub fn new(agent_id: String, content: String) -> Self {
41        let now = chrono::Utc::now().to_rfc3339();
42        let content_hash = crate::hash::compute_content_hash(&content, &agent_id, &now);
43        Self {
44            id: Uuid::now_v7(),
45            agent_id,
46            content,
47            memory_type: MemoryType::Episodic,
48            scope: Scope::Private,
49            importance: 0.5,
50            tags: vec![],
51            metadata: serde_json::json!({}),
52            embedding: None,
53            content_hash,
54            prev_hash: None,
55            source_type: SourceType::Agent,
56            source_id: None,
57            consolidation_state: ConsolidationState::Raw,
58            access_count: 0,
59            org_id: None,
60            thread_id: None,
61            created_at: now.clone(),
62            updated_at: now,
63            last_accessed_at: None,
64            expires_at: None,
65            deleted_at: None,
66            decay_rate: None,
67            created_by: None,
68            version: 1,
69            prev_version_id: None,
70            quarantined: false,
71            quarantine_reason: None,
72            decay_function: None,
73        }
74    }
75
76    /// Create a `MemoryRecord` with all fields specified.
77    /// Intended for storage backends that reconstruct records from database rows.
78    #[allow(clippy::too_many_arguments)]
79    pub fn from_parts(
80        id: Uuid,
81        agent_id: String,
82        content: String,
83        memory_type: MemoryType,
84        scope: Scope,
85        importance: f32,
86        tags: Vec<String>,
87        metadata: serde_json::Value,
88        embedding: Option<Vec<f32>>,
89        content_hash: Vec<u8>,
90        prev_hash: Option<Vec<u8>>,
91        source_type: SourceType,
92        source_id: Option<String>,
93        consolidation_state: ConsolidationState,
94        access_count: u64,
95        org_id: Option<String>,
96        thread_id: Option<String>,
97        created_at: String,
98        updated_at: String,
99        last_accessed_at: Option<String>,
100        expires_at: Option<String>,
101        deleted_at: Option<String>,
102        decay_rate: Option<f32>,
103        created_by: Option<String>,
104        version: u32,
105        prev_version_id: Option<Uuid>,
106        quarantined: bool,
107        quarantine_reason: Option<String>,
108        decay_function: Option<String>,
109    ) -> Self {
110        Self {
111            id,
112            agent_id,
113            content,
114            memory_type,
115            scope,
116            importance,
117            tags,
118            metadata,
119            embedding,
120            content_hash,
121            prev_hash,
122            source_type,
123            source_id,
124            consolidation_state,
125            access_count,
126            org_id,
127            thread_id,
128            created_at,
129            updated_at,
130            last_accessed_at,
131            expires_at,
132            deleted_at,
133            decay_rate,
134            created_by,
135            version,
136            prev_version_id,
137            quarantined,
138            quarantine_reason,
139            decay_function,
140        }
141    }
142
143    pub fn is_deleted(&self) -> bool {
144        self.deleted_at.is_some()
145    }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum MemoryType {
151    Episodic,
152    Semantic,
153    Procedural,
154    Working,
155}
156
157/// Letta-style memory tier. Alias for `MemoryType`; the same four variants
158/// describe both "what kind of memory is this" (Episodic/Semantic) and "how
159/// should the engine treat it" (Working TTL, Procedural importance floor,
160/// Episodic per-session logging). Kept as a single type to avoid carrying two
161/// semantically-redundant fields on every record.
162///
163/// Tier-specific behaviours live on the engine, not on the data model:
164///
165/// * `Working` — auto-expires after `engine.ttl_working_seconds` when the
166///   caller doesn't supply an explicit `expires_at`; always recall-boosted
167///   when the query carries a matching `thread_id` (session id).
168/// * `Procedural` — importance is clamped to a `>=0.8` floor on write; decay
169///   is disabled on recall via `effective_importance_with(Procedural)`.
170/// * `Semantic` — current default behaviour, no special handling.
171/// * `Episodic` — interaction logs; relies on `thread_id` for session scope
172///   and is the prime target for the reflection pass.
173pub type MemoryTier = MemoryType;
174
175impl std::fmt::Display for MemoryType {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self {
178            MemoryType::Episodic => write!(f, "episodic"),
179            MemoryType::Semantic => write!(f, "semantic"),
180            MemoryType::Procedural => write!(f, "procedural"),
181            MemoryType::Working => write!(f, "working"),
182        }
183    }
184}
185
186impl std::str::FromStr for MemoryType {
187    type Err = crate::error::Error;
188    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
189        match s {
190            "episodic" => Ok(MemoryType::Episodic),
191            "semantic" => Ok(MemoryType::Semantic),
192            "procedural" => Ok(MemoryType::Procedural),
193            "working" => Ok(MemoryType::Working),
194            _ => Err(crate::error::Error::Validation(format!(
195                "invalid memory type: {s}"
196            ))),
197        }
198    }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum Scope {
204    Private,
205    Shared,
206    Public,
207    Global,
208}
209
210impl std::fmt::Display for Scope {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        match self {
213            Scope::Private => write!(f, "private"),
214            Scope::Shared => write!(f, "shared"),
215            Scope::Public => write!(f, "public"),
216            Scope::Global => write!(f, "global"),
217        }
218    }
219}
220
221impl std::str::FromStr for Scope {
222    type Err = crate::error::Error;
223    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
224        match s {
225            "private" => Ok(Scope::Private),
226            "shared" => Ok(Scope::Shared),
227            "public" => Ok(Scope::Public),
228            "global" => Ok(Scope::Global),
229            _ => Err(crate::error::Error::Validation(format!(
230                "invalid scope: {s}"
231            ))),
232        }
233    }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum ConsolidationState {
239    Raw,
240    Active,
241    Pending,
242    Consolidated,
243    Archived,
244    Forgotten,
245}
246
247impl std::fmt::Display for ConsolidationState {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        match self {
250            ConsolidationState::Raw => write!(f, "raw"),
251            ConsolidationState::Active => write!(f, "active"),
252            ConsolidationState::Pending => write!(f, "pending"),
253            ConsolidationState::Consolidated => write!(f, "consolidated"),
254            ConsolidationState::Archived => write!(f, "archived"),
255            ConsolidationState::Forgotten => write!(f, "forgotten"),
256        }
257    }
258}
259
260impl std::str::FromStr for ConsolidationState {
261    type Err = crate::error::Error;
262    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
263        match s {
264            "raw" => Ok(ConsolidationState::Raw),
265            "active" => Ok(ConsolidationState::Active),
266            "pending" => Ok(ConsolidationState::Pending),
267            "consolidated" => Ok(ConsolidationState::Consolidated),
268            "archived" => Ok(ConsolidationState::Archived),
269            "forgotten" => Ok(ConsolidationState::Forgotten),
270            _ => Err(crate::error::Error::Validation(format!(
271                "invalid consolidation state: {s}"
272            ))),
273        }
274    }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum SourceType {
280    Agent,
281    Human,
282    System,
283    UserInput,
284    ToolOutput,
285    ModelResponse,
286    Retrieval,
287    Consolidation,
288    Import,
289}
290
291impl std::fmt::Display for SourceType {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        match self {
294            SourceType::Agent => write!(f, "agent"),
295            SourceType::Human => write!(f, "human"),
296            SourceType::System => write!(f, "system"),
297            SourceType::UserInput => write!(f, "user_input"),
298            SourceType::ToolOutput => write!(f, "tool_output"),
299            SourceType::ModelResponse => write!(f, "model_response"),
300            SourceType::Retrieval => write!(f, "retrieval"),
301            SourceType::Consolidation => write!(f, "consolidation"),
302            SourceType::Import => write!(f, "import"),
303        }
304    }
305}
306
307impl std::str::FromStr for SourceType {
308    type Err = crate::error::Error;
309    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
310        match s {
311            "agent" => Ok(SourceType::Agent),
312            "human" => Ok(SourceType::Human),
313            "system" => Ok(SourceType::System),
314            "user_input" => Ok(SourceType::UserInput),
315            "tool_output" => Ok(SourceType::ToolOutput),
316            "model_response" => Ok(SourceType::ModelResponse),
317            "retrieval" => Ok(SourceType::Retrieval),
318            "consolidation" => Ok(SourceType::Consolidation),
319            "import" => Ok(SourceType::Import),
320            _ => Err(crate::error::Error::Validation(format!(
321                "invalid source type: {s}"
322            ))),
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use uuid::Uuid;
331
332    fn sample_record() -> MemoryRecord {
333        MemoryRecord {
334            id: Uuid::now_v7(),
335            agent_id: "agent-1".to_string(),
336            content: "The user prefers dark mode".to_string(),
337            memory_type: MemoryType::Semantic,
338            scope: Scope::Private,
339            importance: 0.8,
340            tags: vec!["preference".to_string(), "ui".to_string()],
341            metadata: serde_json::json!({"source": "conversation"}),
342            embedding: None,
343            content_hash: vec![1, 2, 3],
344            prev_hash: None,
345            source_type: SourceType::Agent,
346            source_id: None,
347            consolidation_state: ConsolidationState::Raw,
348            access_count: 0,
349            org_id: None,
350            thread_id: None,
351            created_at: "2025-01-01T00:00:00Z".to_string(),
352            updated_at: "2025-01-01T00:00:00Z".to_string(),
353            last_accessed_at: None,
354            expires_at: None,
355            deleted_at: None,
356            decay_rate: None,
357            created_by: None,
358            version: 1,
359            prev_version_id: None,
360            quarantined: false,
361            quarantine_reason: None,
362            decay_function: None,
363        }
364    }
365
366    #[test]
367    fn test_serde_roundtrip() {
368        let record = sample_record();
369        let json = serde_json::to_string(&record).unwrap();
370        let deserialized: MemoryRecord = serde_json::from_str(&json).unwrap();
371        assert_eq!(record, deserialized);
372    }
373
374    #[test]
375    fn test_enum_serde() {
376        assert_eq!(
377            serde_json::to_string(&MemoryType::Episodic).unwrap(),
378            "\"episodic\""
379        );
380        assert_eq!(
381            serde_json::to_string(&Scope::Private).unwrap(),
382            "\"private\""
383        );
384        assert_eq!(
385            serde_json::to_string(&SourceType::Agent).unwrap(),
386            "\"agent\""
387        );
388        assert_eq!(
389            serde_json::to_string(&ConsolidationState::Raw).unwrap(),
390            "\"raw\""
391        );
392    }
393
394    #[test]
395    fn test_is_deleted() {
396        let mut record = sample_record();
397        assert!(!record.is_deleted());
398        record.deleted_at = Some("2025-01-02T00:00:00Z".to_string());
399        assert!(record.is_deleted());
400    }
401
402    #[test]
403    fn test_enum_fromstr() {
404        assert_eq!(
405            "episodic".parse::<MemoryType>().unwrap(),
406            MemoryType::Episodic
407        );
408        assert_eq!(
409            "working".parse::<MemoryType>().unwrap(),
410            MemoryType::Working
411        );
412        assert_eq!("shared".parse::<Scope>().unwrap(), Scope::Shared);
413        assert_eq!("human".parse::<SourceType>().unwrap(), SourceType::Human);
414        assert_eq!(
415            "active".parse::<ConsolidationState>().unwrap(),
416            ConsolidationState::Active
417        );
418        assert_eq!(
419            "pending".parse::<ConsolidationState>().unwrap(),
420            ConsolidationState::Pending
421        );
422        assert_eq!(
423            "forgotten".parse::<ConsolidationState>().unwrap(),
424            ConsolidationState::Forgotten
425        );
426        assert!("invalid".parse::<MemoryType>().is_err());
427    }
428
429    #[test]
430    fn test_extended_enums_parse() {
431        // New SourceType variants
432        assert_eq!(
433            "user_input".parse::<SourceType>().unwrap(),
434            SourceType::UserInput
435        );
436        assert_eq!(
437            "tool_output".parse::<SourceType>().unwrap(),
438            SourceType::ToolOutput
439        );
440        assert_eq!(
441            "model_response".parse::<SourceType>().unwrap(),
442            SourceType::ModelResponse
443        );
444        assert_eq!(
445            "retrieval".parse::<SourceType>().unwrap(),
446            SourceType::Retrieval
447        );
448        assert_eq!(
449            "consolidation".parse::<SourceType>().unwrap(),
450            SourceType::Consolidation
451        );
452        assert_eq!("import".parse::<SourceType>().unwrap(), SourceType::Import);
453
454        // New Scope variant
455        assert_eq!("global".parse::<Scope>().unwrap(), Scope::Global);
456
457        // Verify display roundtrip
458        assert_eq!(SourceType::UserInput.to_string(), "user_input");
459        assert_eq!(SourceType::Import.to_string(), "import");
460        assert_eq!(Scope::Global.to_string(), "global");
461    }
462}