Skip to main content

aegis_memory/
entry.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::fmt;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7/// Classifies the kind of information a memory entry represents.
8pub enum MemoryCategory {
9    Fact,
10    Experience,
11    Preference,
12    Skill,
13    Relationship,
14}
15
16impl fmt::Display for MemoryCategory {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Fact => write!(f, "Fact"),
20            Self::Experience => write!(f, "Experience"),
21            Self::Preference => write!(f, "Preference"),
22            Self::Skill => write!(f, "Skill"),
23            Self::Relationship => write!(f, "Relationship"),
24        }
25    }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29/// Indicates the trustworthiness of a memory entry's source.
30pub enum TrustLevel {
31    System,
32    User,
33    Agent,
34    External,
35}
36
37impl TrustLevel {
38    /// Returns `true` if the trust level is `System` or `User`.
39    pub fn is_high(&self) -> bool {
40        matches!(self, Self::System | Self::User)
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45/// A single reinforcement event (positive or negative feedback).
46pub struct Reinforcement {
47    pub timestamp: DateTime<Utc>,
48    pub score: f32,
49    pub context: String,
50    pub related_to: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54/// A scored memory record with confidence, trust, and graph links.
55pub struct MemoryEntry {
56    pub id: String,
57    pub content: String,
58    pub category: MemoryCategory,
59    pub confidence: f32,
60    pub active: bool,
61    pub tags: Vec<String>,
62    pub source: String,
63    pub trust: TrustLevel,
64    pub linked_ids: Vec<String>,
65    pub superseded_by: Option<String>,
66    pub created_at: DateTime<Utc>,
67    pub accessed_at: DateTime<Utc>,
68    pub access_count: u32,
69    pub reinforcement_history: Vec<Reinforcement>,
70    /// Optional hard expiry. `None` = never expires. Backward-compatible:
71    /// pre-existing stored entries deserialize to `None`.
72    #[serde(default)]
73    pub expires_at: Option<DateTime<Utc>>,
74    /// Optional subject key (e.g. `pref:reply_language`) for deterministic
75    /// latest-wins: writing a new entry with the same key supersedes older
76    /// active ones. `None` = unkeyed (no auto-supersession). Backward-compatible.
77    #[serde(default)]
78    pub key: Option<String>,
79}
80
81impl MemoryEntry {
82    /// Create a new memory entry with default confidence (0.8) and user trust.
83    pub fn new(id: impl Into<String>, content: impl Into<String>, category: MemoryCategory, source: impl Into<String>) -> Self {
84        let now = Utc::now();
85        Self {
86            id: id.into(),
87            content: content.into(),
88            category,
89            confidence: 0.8,
90            active: true,
91            tags: Vec::new(),
92            source: source.into(),
93            trust: TrustLevel::User,
94            linked_ids: Vec::new(),
95            superseded_by: None,
96            created_at: now,
97            accessed_at: now,
98            access_count: 0,
99            reinforcement_history: Vec::new(),
100            expires_at: None,
101            key: None,
102        }
103    }
104
105    /// Builder: set the subject key for deterministic latest-wins supersession.
106    pub fn with_key(mut self, key: impl Into<String>) -> Self {
107        self.key = Some(key.into());
108        self
109    }
110
111    /// Builder: set the memory category (schema kind).
112    pub fn with_category(mut self, category: MemoryCategory) -> Self {
113        self.category = category;
114        self
115    }
116
117    /// Builder: set the trust level (e.g. `User` for explicitly pinned memories).
118    pub fn with_trust(mut self, trust: TrustLevel) -> Self {
119        self.trust = trust;
120        self
121    }
122
123    /// Builder: set a time-to-live measured from now.
124    pub fn with_ttl(mut self, ttl: chrono::Duration) -> Self {
125        self.expires_at = Some(Utc::now() + ttl);
126        self
127    }
128
129    /// Whether this entry has passed its hard expiry (if any).
130    pub fn is_expired(&self) -> bool {
131        self.expires_at.is_some_and(|e| Utc::now() >= e)
132    }
133
134    /// Compute effective confidence with access bonus and age decay.
135    pub fn effective_confidence(&self) -> f32 {
136        let base = self.confidence;
137        let access_bonus = ((self.access_count as f32 + 1.0).ln()) * 0.05;
138        let age_days = (Utc::now() - self.created_at).num_days().max(0) as f32;
139        let age_decay = (age_days / 365.0 * 0.3).min(0.3);
140        (base + access_bonus - age_decay).clamp(0.0, 1.0)
141    }
142
143    /// Append a reinforcement event and adjust base confidence.
144    pub fn reinforce(&mut self, score: f32, context: &str) {
145        self.reinforcement_history.push(Reinforcement {
146            timestamp: Utc::now(),
147            score,
148            context: context.to_string(),
149            related_to: None,
150        });
151        self.confidence = (self.confidence + score * 0.1).min(1.0);
152    }
153
154    /// Decrement base confidence; deactivates the entry if it drops below 0.1.
155    pub fn decay_confidence(&mut self, amount: f32) {
156        self.confidence = (self.confidence - amount).max(0.0);
157        if self.confidence < 0.1 {
158            self.active = false;
159        }
160    }
161
162    /// Mark this entry as superseded by `new_id` and deactivate it.
163    pub fn supersede(&mut self, new_id: &str) {
164        self.superseded_by = Some(new_id.to_string());
165        self.active = false;
166    }
167
168    /// Record an access event, incrementing the access count and updating the timestamp.
169    pub fn touch(&mut self) {
170        self.access_count += 1;
171        self.accessed_at = Utc::now();
172    }
173}
174
175/// Tokenize a query for lexical recall: lowercase words ≥2 chars, minus a few
176/// English stopwords (which also strips meta-question noise like "what did we
177/// about"). CJK text (no whitespace) stays as a single token (substring-ish).
178fn tokenize_query(q: &str) -> Vec<String> {
179    const STOP: &[&str] = &[
180        "the", "a", "an", "of", "to", "in", "is", "are", "was", "were", "and", "or", "for", "on",
181        "at", "it", "we", "i", "you", "what", "did", "do", "does", "about", "that", "this", "with",
182        "my", "our", "be", "as", "by", "if", "so", "me",
183    ];
184    q.split(|c: char| !c.is_alphanumeric())
185        .filter(|t| t.chars().count() >= 2 && !STOP.contains(t))
186        .map(|t| t.to_string())
187        .collect()
188}
189
190/// In-memory graph of memory entries keyed by ID.
191pub struct MemoryGraph {
192    pub entries: HashMap<String, MemoryEntry>,
193}
194
195impl Default for MemoryGraph {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl MemoryGraph {
202    /// Create an empty memory graph.
203    pub fn new() -> Self {
204        Self { entries: HashMap::new() }
205    }
206
207    /// Load a graph from a JSON file. Returns an empty graph if the file is
208    /// missing or unparseable, so a missing/corrupt store never blocks startup.
209    pub fn load(path: &std::path::Path) -> Self {
210        match std::fs::read_to_string(path) {
211            Ok(s) => match serde_json::from_str::<HashMap<String, MemoryEntry>>(&s) {
212                Ok(entries) => Self { entries },
213                Err(_) => Self::new(),
214            },
215            Err(_) => Self::new(),
216        }
217    }
218
219    /// Persist the graph to a JSON file (creating parent directories).
220    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
221        if let Some(parent) = path.parent() {
222            std::fs::create_dir_all(parent)?;
223        }
224        let json = serde_json::to_string(&self.entries).map_err(std::io::Error::other)?;
225        std::fs::write(path, json)
226    }
227
228    /// Insert a memory entry into the graph.
229    pub fn insert(&mut self, entry: MemoryEntry) {
230        self.entries.insert(entry.id.clone(), entry);
231    }
232
233    /// Insert an entry, deterministically superseding any *active* entry with
234    /// the same `key` (latest-wins). Superseded entries are kept (active=false)
235    /// for audit/restore — never hard-deleted. Unkeyed entries just insert.
236    pub fn remember_keyed(&mut self, entry: MemoryEntry) {
237        if let Some(ref k) = entry.key {
238            let new_id = entry.id.clone();
239            let stale: Vec<String> = self
240                .entries
241                .values()
242                .filter(|e| e.active && e.key.as_deref() == Some(k.as_str()) && e.id != new_id)
243                .map(|e| e.id.clone())
244                .collect();
245            for old in &stale {
246                if let Some(e) = self.entries.get_mut(old) {
247                    e.supersede(&new_id);
248                }
249            }
250        }
251        self.entries.insert(entry.id.clone(), entry);
252    }
253
254    /// All entries (including inactive/superseded), newest first — for
255    /// `/memory --all` and recovery.
256    pub fn list_all(&self) -> Vec<&MemoryEntry> {
257        let mut v: Vec<&MemoryEntry> = self.entries.values().collect();
258        v.sort_by(|a, b| b.created_at.cmp(&a.created_at));
259        v
260    }
261
262    /// Reactivate a previously superseded/deactivated entry by id. Clears its
263    /// `superseded_by` link. Returns true if the entry existed.
264    pub fn restore(&mut self, id: &str) -> bool {
265        if let Some(e) = self.entries.get_mut(id) {
266            e.active = true;
267            e.superseded_by = None;
268            true
269        } else {
270            false
271        }
272    }
273
274    /// Get a mutable reference to a memory entry by ID.
275    pub fn get_mut(&mut self, id: &str) -> Option<&mut MemoryEntry> {
276        self.entries.get_mut(id)
277    }
278
279    /// Permanently remove a memory entry by ID. Returns true if one was removed.
280    pub fn forget(&mut self, id: &str) -> bool {
281        self.entries.remove(id).is_some()
282    }
283
284    /// Soft-deactivate a memory entry by ID (marks inactive, keeps for audit).
285    /// Returns true if the entry existed and was active.
286    pub fn deactivate(&mut self, id: &str) -> bool {
287        if let Some(entry) = self.entries.get_mut(id) {
288            if entry.active {
289                entry.active = false;
290                return true;
291            }
292        }
293        false
294    }
295
296    /// Bound the graph: drop expired entries first, then (if still over
297    /// `max_entries`) the lowest effective-confidence ones, until at the cap.
298    /// Keeps long-term memory and disk usage bounded on small servers.
299    pub fn prune(&mut self, max_entries: usize) {
300        self.entries.retain(|_, e| !e.is_expired());
301        if self.entries.len() <= max_entries {
302            return;
303        }
304        let mut ranked: Vec<(String, f32)> = self
305            .entries
306            .iter()
307            .map(|(id, e)| (id.clone(), e.effective_confidence()))
308            .collect();
309        // Lowest confidence first.
310        ranked.sort_by(|a, b| {
311            a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
312        });
313        let remove_count = self.entries.len() - max_entries;
314        for (id, _) in ranked.into_iter().take(remove_count) {
315            self.entries.remove(&id);
316        }
317    }
318
319    /// Search active entries by lexical relevance (TF-IDF/BM25-lite over
320    /// content+tags) blended with effective confidence, then truncate to
321    /// `limit`. Dep-free (no vector store) — fits resource-constrained hosts.
322    /// Multi-word / partial-overlap queries recall far better than a whole-query
323    /// substring match. Falls back to whole-query substring for very short queries.
324    pub fn search(&self, query: &str, limit: usize) -> Vec<&MemoryEntry> {
325        let ql = query.trim().to_lowercase();
326        if ql.is_empty() {
327            return Vec::new();
328        }
329        // Candidate docs (active, not expired) + their lowercased haystack.
330        let docs: Vec<(String, &MemoryEntry)> = self
331            .entries
332            .values()
333            .filter(|e| e.active && !e.is_expired())
334            .map(|e| {
335                let hay = format!("{} {}", e.content.to_lowercase(), e.tags.join(" ").to_lowercase());
336                (hay, e)
337            })
338            .collect();
339        if docs.is_empty() {
340            return Vec::new();
341        }
342
343        let terms = tokenize_query(&ql);
344        // Very short query / all stopwords → old whole-query substring behavior.
345        if terms.is_empty() {
346            let mut results: Vec<&MemoryEntry> = docs
347                .iter()
348                .filter(|(hay, _)| hay.contains(&ql))
349                .map(|(_, e)| *e)
350                .collect();
351            results.sort_by(|a, b| {
352                b.effective_confidence()
353                    .partial_cmp(&a.effective_confidence())
354                    .unwrap_or(std::cmp::Ordering::Equal)
355            });
356            results.truncate(limit);
357            return results;
358        }
359
360        let n = docs.len() as f32;
361        // Document frequency per term (how many docs contain it).
362        let mut df: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
363        for t in &terms {
364            let c = docs.iter().filter(|(hay, _)| hay.contains(t.as_str())).count();
365            df.insert(t.as_str(), c);
366        }
367
368        const K1: f32 = 1.5;
369        let mut scored: Vec<(f32, &MemoryEntry)> = docs
370            .iter()
371            .filter_map(|(hay, e)| {
372                let mut lexical = 0.0f32;
373                for t in &terms {
374                    let tf = hay.matches(t.as_str()).count() as f32;
375                    if tf > 0.0 {
376                        let dfi = df.get(t.as_str()).copied().unwrap_or(1).max(1) as f32;
377                        let idf = ((n - dfi + 0.5) / (dfi + 0.5) + 1.0).ln();
378                        lexical += idf * (tf * (K1 + 1.0)) / (tf + K1);
379                    }
380                }
381                // Whole-query substring bonus (never regress vs the old behavior).
382                if hay.contains(&ql) {
383                    lexical += 2.0;
384                }
385                if lexical <= 0.0 {
386                    return None;
387                }
388                // Prefer entries that are both relevant and confident.
389                let blended = lexical * (0.6 + 0.4 * e.effective_confidence());
390                Some((blended, *e))
391            })
392            .collect();
393
394        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
395        scored.into_iter().take(limit).map(|(_, e)| e).collect()
396    }
397
398    /// Return all entries directly linked to the given entry.
399    pub fn linked(&self, id: &str) -> Vec<&MemoryEntry> {
400        self.entries.get(id)
401            .map(|e| {
402                e.linked_ids.iter()
403                    .filter_map(|lid| self.entries.get(lid))
404                    .collect()
405            })
406            .unwrap_or_default()
407    }
408
409    /// Replace an old entry with a new one and create a bidirectional link.
410    pub fn supersede(&mut self, old_id: &str, new_id: &str) {
411        if let Some(old) = self.entries.get_mut(old_id) {
412            old.supersede(new_id);
413            let old_id_owned = old_id.to_string();
414            if let Some(new_entry) = self.entries.get_mut(new_id) {
415                if !new_entry.linked_ids.contains(&old_id_owned) {
416                    new_entry.linked_ids.push(old_id_owned);
417                }
418            }
419        }
420    }
421
422    /// Decay confidence of all active entries by `amount`.
423    pub fn decay_all(&mut self, amount: f32) {
424        for entry in self.entries.values_mut() {
425            if entry.active {
426                entry.decay_confidence(amount);
427            }
428        }
429    }
430
431    /// Remove inactive entries that have not been accessed in 30 days.
432    pub fn prune_inactive(&mut self) -> usize {
433        let threshold = Utc::now() - chrono::Duration::days(30);
434        let to_remove: Vec<String> = self.entries.iter()
435            .filter(|(_, e)| !e.active && e.accessed_at < threshold)
436            .map(|(id, _)| id.clone())
437            .collect();
438        let count = to_remove.len();
439        for id in to_remove {
440            self.entries.remove(&id);
441        }
442        count
443    }
444
445    /// BFS traversal of reinforcement-related entries up to `max_hops` hops.
446    pub fn neighbors(&self, id: &str, max_hops: usize) -> Vec<&MemoryEntry> {
447        let mut visited = HashSet::new();
448        let mut queue = VecDeque::new();
449        visited.insert(id.to_string());
450        queue.push_back((id.to_string(), 0usize));
451
452        let mut result = Vec::new();
453        while let Some((current_id, depth)) = queue.pop_front() {
454            if depth >= max_hops {
455                continue;
456            }
457            if let Some(entry) = self.entries.get(&current_id) {
458                for r in &entry.reinforcement_history {
459                    if let Some(ref neighbor_id) = r.related_to {
460                        if visited.insert(neighbor_id.clone()) {
461                            if let Some(neighbor) = self.entries.get(neighbor_id) {
462                                result.push(neighbor);
463                            }
464                            queue.push_back((neighbor_id.clone(), depth + 1));
465                        }
466                    }
467                }
468            }
469        }
470        result
471    }
472
473    /// Search entries whose category or trust matches the given tag string.
474    pub fn search_by_tag(&self, tag: &str) -> Vec<&MemoryEntry> {
475        let tag_lower = tag.to_lowercase();
476        self.entries.values()
477            .filter(|e| {
478                e.category.to_string().to_lowercase().contains(&tag_lower)
479                    || e.trust.is_high()
480            })
481            .collect()
482    }
483
484    /// Remove external-trust entries not accessed within `max_age_days`.
485    pub fn prune_stale(&mut self, max_age_days: u64) -> usize {
486        let threshold = Utc::now() - chrono::Duration::days(max_age_days as i64);
487        let to_remove: Vec<String> = self.entries.iter()
488            .filter(|(_, e)| e.trust == TrustLevel::External && e.accessed_at < threshold)
489            .map(|(id, _)| id.clone())
490            .collect();
491        let count = to_remove.len();
492        for id in to_remove {
493            self.entries.remove(&id);
494        }
495        count
496    }
497}
498
499/// Available bytes on the filesystem holding `path` (best-effort; `None` if it
500/// can't be determined). Unix uses `statvfs`; other platforms return `None`.
501pub fn available_disk_bytes(path: &std::path::Path) -> Option<u64> {
502    #[cfg(unix)]
503    {
504        use std::os::unix::ffi::OsStrExt;
505        // statvfs needs a path that exists — fall back to the parent dir, or ".".
506        let target = if path.exists() {
507            path
508        } else {
509            path.parent().unwrap_or_else(|| std::path::Path::new("."))
510        };
511        let cpath = std::ffi::CString::new(target.as_os_str().as_bytes()).ok()?;
512        // SAFETY: statvfs fills a zeroed struct; we only read it on success (0).
513        unsafe {
514            let mut s: libc::statvfs = std::mem::zeroed();
515            if libc::statvfs(cpath.as_ptr(), &mut s) == 0 {
516                return Some((s.f_bavail as u64).saturating_mul(s.f_frsize as u64));
517            }
518        }
519        None
520    }
521    #[cfg(not(unix))]
522    {
523        let _ = path;
524        None
525    }
526}
527
528/// Disk-aware memory cap: never exceeds `configured`, but on a small/full disk
529/// it shrinks further (≤5% of free space, ~600 bytes/entry, ≤64 MB budget),
530/// while always keeping a useful floor so some user content is still remembered.
531/// Memory is cheap (a few MB) and must not be skimped — only a genuinely tiny
532/// disk shrinks it, and even then it keeps a healthy floor.
533pub fn disk_aware_max_entries(configured: usize, path: &std::path::Path) -> usize {
534    const FLOOR: usize = 500;
535    const BYTES_PER_ENTRY: u64 = 600;
536    match available_disk_bytes(path) {
537        Some(free) => {
538            let budget = (free / 20).min(64 * 1024 * 1024);
539            let by_disk = (budget / BYTES_PER_ENTRY) as usize;
540            configured.min(by_disk.max(FLOOR))
541        }
542        None => configured,
543    }
544}
545
546
547#[cfg(test)]
548mod search_tests {
549    use super::*;
550
551    fn entry(id: &str, content: &str) -> MemoryEntry {
552        MemoryEntry::new(id, content, MemoryCategory::Fact, "test")
553    }
554
555    #[test]
556    fn test_multiword_partial_recall() {
557        let mut g = MemoryGraph::new();
558        g.insert(entry("1", "user prefers PostgreSQL for the database"));
559        g.insert(entry("2", "user likes Python and Rust"));
560        // Whole-query substring would miss this; term overlap recalls it.
561        let hits = g.search("backup postgresql database", 5);
562        assert!(hits.iter().any(|e| e.id == "1"), "should recall the postgres/database entry");
563    }
564
565    #[test]
566    fn test_more_term_overlap_ranks_first() {
567        let mut g = MemoryGraph::new();
568        g.insert(entry("multi", "deploy the service to AWS ECS with docker"));
569        g.insert(entry("single", "docker is installed"));
570        let hits = g.search("deploy service docker", 5);
571        assert!(!hits.is_empty());
572        assert_eq!(hits[0].id, "multi", "more term overlap should rank first");
573    }
574
575    #[test]
576    fn test_stopwords_ignored() {
577        let mut g = MemoryGraph::new();
578        g.insert(entry("1", "the user runs nginx"));
579        // "what do we ..." stopwords stripped; "nginx" carries the match.
580        let hits = g.search("what do we know about nginx", 5);
581        assert!(hits.iter().any(|e| e.id == "1"));
582    }
583
584    #[test]
585    fn test_no_match_returns_empty() {
586        let mut g = MemoryGraph::new();
587        g.insert(entry("1", "user prefers PostgreSQL"));
588        let hits = g.search("kubernetes helm charts", 5);
589        assert!(hits.is_empty(), "unrelated query should not recall");
590    }
591
592    #[test]
593    fn test_inactive_excluded() {
594        let mut g = MemoryGraph::new();
595        let mut e = entry("1", "nginx config");
596        e.active = false;
597        g.insert(e);
598        assert!(g.search("nginx", 5).is_empty());
599    }
600}