Skip to main content

mnemo/
memory.rs

1//! The agent memory model (Phase 5 of the build plan).
2//!
3//! This is what makes Mnemo a *memory engine* rather than a generic vector
4//! store. Memories are typed (episodic / semantic / procedural / working),
5//! carry lifecycle metadata (importance, access stats, optional TTL), and are
6//! retrieved with a **multi-signal score** that blends semantic similarity
7//! with recency, importance, and access frequency.
8
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13use ulid::Ulid;
14
15/// Current unix time in whole seconds.
16pub fn now_secs() -> i64 {
17    SystemTime::now()
18        .duration_since(UNIX_EPOCH)
19        .map(|d| d.as_secs() as i64)
20        .unwrap_or(0)
21}
22
23/// The four kinds of agent memory.
24#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Hash)]
25pub enum MemoryType {
26    /// What happened — conversation turns, events, interactions.
27    Episodic,
28    /// What is known — facts, entities, relationships, beliefs.
29    Semantic,
30    /// What to do — preferences, behavioral patterns, rules.
31    Procedural,
32    /// Right now — current-session context and scratchpad.
33    Working,
34}
35
36impl MemoryType {
37    /// Parse from a lowercase string (used by the CLI / SDK surface).
38    pub fn parse(s: &str) -> Option<MemoryType> {
39        match s.to_ascii_lowercase().as_str() {
40            "episodic" => Some(MemoryType::Episodic),
41            "semantic" => Some(MemoryType::Semantic),
42            "procedural" => Some(MemoryType::Procedural),
43            "working" => Some(MemoryType::Working),
44            _ => None,
45        }
46    }
47
48    /// Lowercase string form.
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            MemoryType::Episodic => "episodic",
52            MemoryType::Semantic => "semantic",
53            MemoryType::Procedural => "procedural",
54            MemoryType::Working => "working",
55        }
56    }
57}
58
59/// Visibility of a memory across agents sharing one `.mnemo` file.
60#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
61pub enum Scope {
62    /// Visible only to the agent that created it.
63    Private,
64    /// Visible to every agent with read access.
65    Shared,
66}
67
68/// A single memory entry.
69#[derive(Serialize, Deserialize, Clone, Debug)]
70pub struct Memory {
71    /// Time-sortable unique ID.
72    pub id: Ulid,
73    /// Cognitive category of this memory.
74    pub memory_type: MemoryType,
75    /// Embedding of `content`.
76    pub vector: Vec<f32>,
77    /// The actual text/data of the memory.
78    pub content: String,
79    /// Free-form structured metadata.
80    pub metadata: Map<String, Value>,
81    /// Which agent created this memory.
82    pub agent_id: String,
83    /// Which session produced it (relevant for episodic memory).
84    pub session_id: Option<String>,
85    /// Visibility scope (private to its agent, or shared).
86    pub scope: Scope,
87    /// Unix seconds at creation.
88    pub created_at: i64,
89    /// Unix seconds of the most recent retrieval. Updated by `recall`.
90    pub accessed_at: i64,
91    /// How many times this memory has been retrieved.
92    pub access_count: u32,
93    /// Importance in `[0.0, 1.0]`; higher importance resists decay.
94    pub importance: f32,
95    /// Optional lifetime in seconds. After expiry the memory is skipped by
96    /// `recall` and removed by `compact`.
97    pub ttl_secs: Option<i64>,
98}
99
100impl Memory {
101    /// Create a new memory with sensible defaults. The ID and timestamps are
102    /// assigned now; tune the rest with the `with_*` builders.
103    pub fn new(content: impl Into<String>, memory_type: MemoryType, vector: Vec<f32>) -> Self {
104        let now = now_secs();
105        Memory {
106            id: Ulid::new(),
107            memory_type,
108            vector,
109            content: content.into(),
110            metadata: Map::new(),
111            agent_id: "default".to_string(),
112            session_id: None,
113            scope: Scope::Private,
114            created_at: now,
115            accessed_at: now,
116            access_count: 0,
117            importance: 0.5,
118            ttl_secs: None,
119        }
120    }
121
122    /// Set the owning agent.
123    pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
124        self.agent_id = agent_id.into();
125        self
126    }
127    /// Set the session ID.
128    pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
129        self.session_id = Some(session_id.into());
130        self
131    }
132    /// Set the importance score (clamped to `[0,1]`).
133    pub fn with_importance(mut self, importance: f32) -> Self {
134        self.importance = importance.clamp(0.0, 1.0);
135        self
136    }
137    /// Set the visibility scope.
138    pub fn with_scope(mut self, scope: Scope) -> Self {
139        self.scope = scope;
140        self
141    }
142    /// Attach a TTL in seconds.
143    pub fn with_ttl(mut self, ttl_secs: i64) -> Self {
144        self.ttl_secs = Some(ttl_secs);
145        self
146    }
147    /// Attach a metadata key/value pair.
148    pub fn with_meta(mut self, key: impl Into<String>, value: Value) -> Self {
149        self.metadata.insert(key.into(), value);
150        self
151    }
152
153    /// True if the memory's TTL has elapsed as of `now`.
154    pub fn is_expired(&self, now: i64) -> bool {
155        match self.ttl_secs {
156            Some(ttl) => now - self.created_at >= ttl,
157            None => false,
158        }
159    }
160
161    /// Build the **canonical scaffold manifest** for a freshly-initialised
162    /// database. This is the default "I am a brand-new MNemo file" memory
163    /// inserted by `mnemo init` (and surfaced by `mnemo about`) so every new
164    /// database is self-describing from birth — no opt-in step required.
165    ///
166    /// The scaffold is a placeholder: the vector is all-zeros (it doesn't
167    /// pretend to carry semantic content), and `metadata.scaffold = true`
168    /// marks it as auto-generated so tooling and humans can tell it apart
169    /// from a hand-written manifest. The expected workflow is for the file's
170    /// author to replace this memory with one that records the project's
171    /// actual embedder, agent_id default, and conventions.
172    ///
173    /// See [`crate::Mnemo::about`] for the rest of the convention.
174    pub fn scaffold_manifest(dimensions: usize) -> Self {
175        let content = format!(
176            "MNEMO MANIFEST (scaffold) — Fresh {}-dimensional database created \
177             by `mnemo init`. This is a placeholder. Replace it with a memory \
178             that records your project's embedder (name, dimensions, normalize), \
179             default agent_id, and metadata conventions; keep the metadata keys \
180             `area=\"onboarding\"` and `topic=\"manifest\"` so `mnemo about` \
181             continues to surface it as the headline orientation point. Drop \
182             `metadata.scaffold` once you've replaced this entry so tooling \
183             knows the manifest has been curated.",
184            dimensions
185        );
186        let mut metadata = Map::new();
187        metadata.insert("area".into(), Value::String("onboarding".into()));
188        metadata.insert("topic".into(), Value::String("manifest".into()));
189        metadata.insert("scaffold".into(), Value::Bool(true));
190        metadata.insert(
191            "engine_version".into(),
192            Value::String(env!("CARGO_PKG_VERSION").into()),
193        );
194        metadata.insert(
195            "dimensions".into(),
196            Value::Number(serde_json::Number::from(dimensions as u64)),
197        );
198        let now = now_secs();
199        Memory {
200            id: Ulid::new(),
201            memory_type: MemoryType::Semantic,
202            vector: vec![0.0; dimensions],
203            content,
204            metadata,
205            agent_id: "mnemo".to_string(),
206            session_id: None,
207            scope: Scope::Shared,
208            created_at: now,
209            accessed_at: now,
210            access_count: 0,
211            importance: 1.0,
212            ttl_secs: None,
213        }
214    }
215}
216
217/// Distance/similarity metric for vector comparison.
218#[derive(Clone, Copy, Debug, PartialEq, Eq)]
219pub enum Metric {
220    /// Cosine similarity (orientation only).
221    Cosine,
222    /// Negative Euclidean distance, mapped into a `(0,1]` similarity.
223    L2,
224    /// Raw dot product.
225    Dot,
226}
227
228/// Compute a *similarity* (higher = more relevant) under the given metric.
229pub fn similarity(metric: Metric, a: &[f32], b: &[f32]) -> f32 {
230    match metric {
231        Metric::Cosine => cosine(a, b),
232        Metric::Dot => dot(a, b),
233        Metric::L2 => {
234            let d = euclidean(a, b);
235            1.0 / (1.0 + d)
236        }
237    }
238}
239
240fn dot(a: &[f32], b: &[f32]) -> f32 {
241    a.iter().zip(b).map(|(x, y)| x * y).sum()
242}
243
244fn cosine(a: &[f32], b: &[f32]) -> f32 {
245    let na = dot(a, a).sqrt();
246    let nb = dot(b, b).sqrt();
247    if na == 0.0 || nb == 0.0 {
248        0.0
249    } else {
250        dot(a, b) / (na * nb)
251    }
252}
253
254fn euclidean(a: &[f32], b: &[f32]) -> f32 {
255    a.iter()
256        .zip(b)
257        .map(|(x, y)| (x - y) * (x - y))
258        .sum::<f32>()
259        .sqrt()
260}
261
262/// Weights for the multi-signal recall score.
263///
264/// `score = α·similarity + β·recency + γ·importance + δ·ln(access_count + 1)`
265#[derive(Clone, Copy, Debug)]
266pub struct ScoreWeights {
267    /// Weight on semantic similarity.
268    pub alpha: f32,
269    /// Weight on recency decay.
270    pub beta: f32,
271    /// Weight on the importance score.
272    pub gamma: f32,
273    /// Weight on access frequency.
274    pub delta: f32,
275    /// Half-life of the recency term, in seconds.
276    pub half_life_secs: f32,
277}
278
279impl Default for ScoreWeights {
280    fn default() -> Self {
281        // Tuned for conversational agents: similarity dominates, with a
282        // meaningful recency nudge. Half-life of 7 days.
283        Self {
284            alpha: 1.0,
285            beta: 0.3,
286            gamma: 0.2,
287            delta: 0.1,
288            half_life_secs: 7.0 * 24.0 * 3600.0,
289        }
290    }
291}
292
293impl ScoreWeights {
294    /// Combine the four signals into a single score.
295    pub fn score(&self, similarity: f32, age_secs: f32, importance: f32, access_count: u32) -> f32 {
296        let recency = (-std::f32::consts::LN_2 * age_secs.max(0.0) / self.half_life_secs).exp();
297        let frequency = ((access_count as f32) + 1.0).ln();
298        self.alpha * similarity
299            + self.beta * recency
300            + self.gamma * importance
301            + self.delta * frequency
302    }
303}