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)]
7pub 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)]
29pub enum TrustLevel {
31 System,
32 User,
33 Agent,
34 External,
35}
36
37impl TrustLevel {
38 pub fn is_high(&self) -> bool {
40 matches!(self, Self::System | Self::User)
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub 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)]
54pub 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 #[serde(default)]
73 pub expires_at: Option<DateTime<Utc>>,
74 #[serde(default)]
78 pub key: Option<String>,
79}
80
81impl MemoryEntry {
82 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 pub fn with_key(mut self, key: impl Into<String>) -> Self {
107 self.key = Some(key.into());
108 self
109 }
110
111 pub fn with_category(mut self, category: MemoryCategory) -> Self {
113 self.category = category;
114 self
115 }
116
117 pub fn with_trust(mut self, trust: TrustLevel) -> Self {
119 self.trust = trust;
120 self
121 }
122
123 pub fn with_ttl(mut self, ttl: chrono::Duration) -> Self {
125 self.expires_at = Some(Utc::now() + ttl);
126 self
127 }
128
129 pub fn is_expired(&self) -> bool {
131 self.expires_at.is_some_and(|e| Utc::now() >= e)
132 }
133
134 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 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 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 pub fn supersede(&mut self, new_id: &str) {
164 self.superseded_by = Some(new_id.to_string());
165 self.active = false;
166 }
167
168 pub fn touch(&mut self) {
170 self.access_count += 1;
171 self.accessed_at = Utc::now();
172 }
173}
174
175fn 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
190pub 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 pub fn new() -> Self {
204 Self { entries: HashMap::new() }
205 }
206
207 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 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 pub fn insert(&mut self, entry: MemoryEntry) {
230 self.entries.insert(entry.id.clone(), entry);
231 }
232
233 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 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 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 pub fn get_mut(&mut self, id: &str) -> Option<&mut MemoryEntry> {
276 self.entries.get_mut(id)
277 }
278
279 pub fn forget(&mut self, id: &str) -> bool {
281 self.entries.remove(id).is_some()
282 }
283
284 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 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 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 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 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 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 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 if hay.contains(&ql) {
383 lexical += 2.0;
384 }
385 if lexical <= 0.0 {
386 return None;
387 }
388 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 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 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 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 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 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(¤t_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 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 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
499pub fn available_disk_bytes(path: &std::path::Path) -> Option<u64> {
502 #[cfg(unix)]
503 {
504 use std::os::unix::ffi::OsStrExt;
505 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 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
528pub 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 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 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}