1use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use serde_json::{json, Value};
23use std::collections::HashSet;
24use std::path::PathBuf;
25use std::sync::Mutex;
26
27use crate::error::{Error, Result};
28use crate::llm::ToolSpec;
29use crate::tools::Tool;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Fact {
38 pub id: String,
39 pub text: String,
40 #[serde(default)]
41 pub tags: Vec<String>,
42 #[serde(default)]
43 pub source: Option<String>,
44 pub created_at: String,
45 pub last_accessed: String,
46 pub access_count: u64,
47 #[serde(default)]
48 pub superseded_by: Option<String>,
49}
50
51impl Fact {
52 fn is_active(&self) -> bool {
54 self.superseded_by.is_none()
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct FactStore {
61 pub facts: Vec<Fact>,
62}
63
64impl FactStore {
65 fn load(path: &std::path::Path) -> Result<Self> {
67 if !path.exists() {
68 return Ok(Self::default());
69 }
70 let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
71 name: "facts".into(),
72 call_id: None,
73 message: format!("failed to read facts file: {e}"),
74 })?;
75 let mut facts = Vec::new();
76 for (i, line) in raw.lines().enumerate() {
77 let line = line.trim();
78 if line.is_empty() {
79 continue;
80 }
81 match serde_json::from_str::<Fact>(line) {
82 Ok(fact) => facts.push(fact),
83 Err(e) => {
84 return Err(Error::Tool {
85 name: "facts".into(),
86 call_id: None,
87 message: format!("malformed fact at line {}: {e}", i + 1),
88 });
89 }
90 }
91 }
92 Ok(Self { facts })
93 }
94
95 fn save(&self, path: &std::path::Path) -> Result<()> {
97 if let Some(parent) = path.parent() {
98 std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
99 name: "facts".into(),
100 call_id: None,
101 message: format!("failed to create facts directory: {e}"),
102 })?;
103 }
104 let mut out = String::new();
105 for fact in &self.facts {
106 out.push_str(&serde_json::to_string(fact).map_err(|e| Error::Tool {
107 name: "facts".into(),
108 call_id: None,
109 message: format!("failed to serialize fact: {e}"),
110 })?);
111 out.push('\n');
112 }
113 std::fs::write(path, out).map_err(|e| Error::Tool {
114 name: "facts".into(),
115 call_id: None,
116 message: format!("failed to write facts file: {e}"),
117 })?;
118 Ok(())
119 }
120
121 fn next_id(&self) -> String {
123 let max = self
124 .facts
125 .iter()
126 .filter_map(|f| f.id.strip_prefix('F'))
127 .filter_map(|s| s.parse::<u32>().ok())
128 .max()
129 .unwrap_or(0);
130 format!("F{}", max + 1)
131 }
132
133 fn add(&mut self, text: String, tags: Vec<String>, source: Option<String>) -> String {
135 let id = self.next_id();
136 let now = chrono_now_rfc3339();
137 self.facts.push(Fact {
138 id: id.clone(),
139 text,
140 tags,
141 source,
142 created_at: now.clone(),
143 last_accessed: now,
144 access_count: 0,
145 superseded_by: None,
146 });
147 id
148 }
149
150 fn get(&self, id: &str) -> Option<&Fact> {
152 self.facts.iter().find(|f| f.id == id)
153 }
154
155 fn get_mut(&mut self, id: &str) -> Option<&mut Fact> {
157 self.facts.iter_mut().find(|f| f.id == id)
158 }
159
160 fn soft_delete(&mut self, id: &str, superseded_by: &str) -> bool {
162 if let Some(fact) = self.get_mut(id) {
163 fact.superseded_by = Some(superseded_by.to_string());
164 true
165 } else {
166 false
167 }
168 }
169
170 fn active_facts(&self) -> Vec<&Fact> {
172 self.facts.iter().filter(|f| f.is_active()).collect()
173 }
174
175 fn evict_to_cap(&mut self, cap: usize) -> usize {
179 let active_count = self.active_facts().len();
180 if active_count <= cap {
181 return 0;
182 }
183 let to_remove = active_count - cap;
184
185 let now_secs = std::time::SystemTime::now()
187 .duration_since(std::time::UNIX_EPOCH)
188 .unwrap_or_default()
189 .as_secs_f64();
190 let mut scored: Vec<(usize, f64)> = self
191 .facts
192 .iter()
193 .enumerate()
194 .filter(|(_, f)| f.is_active())
195 .map(|(i, f)| {
196 (
197 i,
198 fact_staleness(now_secs, &f.last_accessed, f.access_count),
199 )
200 })
201 .collect();
202 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
204
205 let mut evicted = 0;
206 for (idx, _) in scored.iter().take(to_remove) {
207 self.facts[*idx].superseded_by = Some("__evicted__".to_string());
209 evicted += 1;
210 }
211 evicted
212 }
213}
214
215#[cfg_attr(test, mutants::skip)]
222fn fact_staleness(now_secs: f64, last_accessed: &str, access_count: u64) -> f64 {
223 let last_access_secs = rfc3339_to_secs(last_accessed).unwrap_or(0.0);
224 let days_since = (now_secs - last_access_secs) / 86400.0;
225 days_since * (1.0 / (access_count.max(1) as f64))
226}
227
228#[derive(Debug, Clone)]
234pub struct ScoredFact {
235 pub fact: Fact,
236 pub score: f64,
237}
238
239fn tokenize(text: &str) -> Vec<String> {
241 text.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
242 .filter(|s| !s.is_empty())
243 .map(|s| s.to_lowercase())
244 .collect()
245}
246
247const STOP_WORDS: &[&str] = &[
249 "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
250 "from", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does",
251 "did", "will", "would", "could", "should", "may", "might", "shall", "can", "it", "its", "this",
252 "that", "these", "those", "i", "you", "he", "she", "we", "they", "not", "no", "nor", "so",
253 "if", "then", "than", "too", "very", "just", "about", "up", "out", "also", "more", "some",
254 "any", "each", "every", "all", "both", "few", "most", "other", "into", "over", "such", "only",
255 "own", "same", "as", "but", "not",
256];
257
258pub fn search_facts(
268 facts: &[&Fact],
269 query: &str,
270 tag: Option<&str>,
271 limit: usize,
272) -> Vec<ScoredFact> {
273 let query_terms: Vec<String> = tokenize(query)
274 .into_iter()
275 .filter(|t| !STOP_WORDS.contains(&t.as_str()))
276 .collect();
277
278 let now_secs = std::time::SystemTime::now()
279 .duration_since(std::time::UNIX_EPOCH)
280 .unwrap_or_default()
281 .as_secs_f64();
282
283 let mut scored: Vec<ScoredFact> = facts
284 .iter()
285 .filter(|f| f.is_active())
286 .filter(|f| {
287 tag.map_or(true, |t| f.tags.iter().any(|ft| ft == t))
289 })
290 .filter(|f| {
291 if query_terms.is_empty() {
293 return true;
294 }
295 let text_lower = f.text.to_lowercase();
297 let tag_text: String = f.tags.join(" ").to_lowercase();
298 query_terms
299 .iter()
300 .any(|t| text_lower.contains(t) || tag_text.contains(t))
301 })
302 .map(|f| ScoredFact {
303 fact: (*f).clone(),
304 score: relevance_score(f, &query_terms, now_secs),
305 })
306 .collect();
307
308 scored.sort_by(|a, b| {
310 b.score
311 .partial_cmp(&a.score)
312 .unwrap_or(std::cmp::Ordering::Equal)
313 });
314 scored.truncate(limit);
315 scored
316}
317
318#[cfg_attr(test, mutants::skip)]
325fn relevance_score(f: &Fact, query_terms: &[String], now_secs: f64) -> f64 {
326 let text_lower = f.text.to_lowercase();
327 let tag_text: String = f.tags.join(" ").to_lowercase();
328
329 let term_frequency = if query_terms.is_empty() {
330 1.0
331 } else {
332 let present = query_terms
333 .iter()
334 .filter(|t| text_lower.contains(t.as_str()) || tag_text.contains(t.as_str()))
335 .count();
336 present as f64 / query_terms.len() as f64
337 };
338
339 let tag_match = if query_terms.iter().any(|t| tag_text.contains(t)) {
340 1.2
341 } else {
342 1.0
343 };
344
345 let created_secs = rfc3339_to_secs(&f.created_at).unwrap_or(0.0);
346 let days_since = ((now_secs - created_secs) / 86400.0).max(0.0);
347 let recency_boost = 1.0 + 0.1 / (days_since + 1.0);
348 let popularity_boost = 1.0 + 0.05 * ((f.access_count + 1) as f64).ln();
349
350 term_frequency * tag_match * recency_boost * popularity_boost
351}
352
353fn jaccard_similarity(a: &str, b: &str) -> f64 {
359 let tokens_a: HashSet<String> = tokenize(a).into_iter().collect();
360 let tokens_b: HashSet<String> = tokenize(b).into_iter().collect();
361
362 if tokens_a.is_empty() && tokens_b.is_empty() {
363 return 1.0;
364 }
365
366 let intersection: HashSet<&String> = tokens_a.intersection(&tokens_b).collect();
367 let union_size = tokens_a.len() + tokens_b.len() - intersection.len();
368 if union_size == 0 {
369 return 0.0;
370 }
371 intersection.len() as f64 / union_size as f64
372}
373
374#[derive(Debug)]
376enum DuplicateResult {
377 SupersedeExisting(String),
379 KeepExisting(String),
381}
382
383fn find_duplicate(facts: &[&Fact], text: &str, threshold: f64) -> Option<DuplicateResult> {
386 for fact in facts {
387 if !fact.is_active() {
388 continue;
389 }
390 let sim = jaccard_similarity(&fact.text, text);
391 if sim >= threshold {
392 if text.len() > fact.text.len() {
393 return Some(DuplicateResult::SupersedeExisting(fact.id.clone()));
394 }
395 return Some(DuplicateResult::KeepExisting(fact.id.clone()));
396 }
397 }
398 None
399}
400
401#[cfg_attr(test, mutants::skip)]
411fn chrono_now_rfc3339() -> String {
412 let dur = std::time::SystemTime::now()
413 .duration_since(std::time::UNIX_EPOCH)
414 .unwrap_or_default();
415 let secs = dur.as_secs();
416 let days = secs / 86400;
417 let time_secs = secs % 86400;
418 let hours = time_secs / 3600;
419 let minutes = (time_secs % 3600) / 60;
420 let seconds = time_secs % 60;
421
422 let (year, month, day) = days_to_date(days);
423 format!(
424 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
425 year, month, day, hours, minutes, seconds
426 )
427}
428
429fn days_to_date(mut days: u64) -> (u64, u64, u64) {
431 let mut year: u64 = 1970;
432 loop {
433 let days_in_year = if is_leap(year) { 366 } else { 365 };
434 if days < days_in_year {
435 break;
436 }
437 days = subtract_year_days(days, days_in_year);
441 year += 1;
442 }
443 let months_days: [u64; 12] = if is_leap(year) {
444 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
445 } else {
446 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
447 };
448 let mut month: u64 = 1;
449 for &md in &months_days {
450 if days < md {
451 break;
452 }
453 days -= md;
454 month += 1;
455 }
456 let day = days + 1;
457 (year, month, day)
458}
459
460fn is_leap(year: u64) -> bool {
461 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
462}
463
464#[cfg_attr(test, mutants::skip)]
469fn subtract_year_days(days: u64, days_in_year: u64) -> u64 {
470 days - days_in_year
471}
472
473fn rfc3339_to_secs(s: &str) -> Option<f64> {
475 if s.len() < 20 {
477 return None;
478 }
479 let year: u64 = s[0..4].parse().ok()?;
480 let month: u64 = s[5..7].parse().ok()?;
481 let day: u64 = s[8..10].parse().ok()?;
482 let hour: u64 = s[11..13].parse().ok()?;
483 let min: u64 = s[14..16].parse().ok()?;
484 let sec: u64 = s[17..19].parse().ok()?;
485
486 let days = days_since_epoch(year, month, day);
487 Some((days * 86400 + hour * 3600 + min * 60 + sec) as f64)
488}
489
490fn days_since_epoch(year: u64, month: u64, day: u64) -> u64 {
491 let mut total = 0u64;
492 for yr in 1970..year {
493 total += if is_leap(yr) { 366 } else { 365 };
494 }
495 let months_days: [u64; 12] = if is_leap(year) {
496 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
497 } else {
498 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
499 };
500 for md in months_days.iter().take((month - 1) as usize) {
501 total += md;
502 }
503 total + day - 1
504}
505
506pub fn facts_path(workspace: &std::path::Path, scope: &str) -> PathBuf {
512 match scope {
513 "global" => {
514 if let Some(home) = std::env::var_os("HOME") {
515 PathBuf::from(home)
516 .join(".recursive")
517 .join("memory")
518 .join("facts.jsonl")
519 } else {
520 workspace
521 .join(".recursive")
522 .join("memory")
523 .join("facts.jsonl")
524 }
525 }
526 _ => workspace
527 .join(".recursive")
528 .join("memory")
529 .join("facts.jsonl"),
530 }
531}
532
533pub fn load_facts(workspace: &std::path::Path, scope: &str) -> Result<FactStore> {
535 let path = facts_path(workspace, scope);
536 FactStore::load(&path)
537}
538
539pub fn facts_summary(workspace: &std::path::Path, limit: usize) -> String {
543 let workspace_store = load_facts(workspace, "workspace").unwrap_or_default();
545 let global_store = load_facts(workspace, "global").unwrap_or_default();
546
547 let mut all_facts: Vec<Fact> = global_store
550 .active_facts()
551 .into_iter()
552 .chain(workspace_store.active_facts())
553 .cloned()
554 .collect();
555
556 if all_facts.is_empty() {
557 return String::new();
558 }
559
560 all_facts.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
562 let sorted: Vec<&Fact> = all_facts.iter().collect();
563
564 let mut lines: Vec<String> = Vec::new();
565 lines.push(format!(
566 "# Facts (top {} most recently accessed; use `recall` for more)",
567 limit
568 ));
569 for fact in sorted.iter().take(limit) {
570 let tags_str = if fact.tags.is_empty() {
571 String::new()
572 } else {
573 format!(" [{}]", fact.tags.join(","))
574 };
575 let source_str = if let Some(ref src) = fact.source {
576 format!(" (source: {})", src)
577 } else {
578 String::new()
579 };
580 let text_preview = if fact.text.chars().count() > 120 {
581 format!("{}...", crate::truncate_str(&fact.text, 117))
582 } else {
583 fact.text.clone()
584 };
585 lines.push(format!(
586 "- {}{}{} {}",
587 fact.id, tags_str, source_str, text_preview
588 ));
589 }
590 lines.join("\n")
591}
592
593const FACTS_CAP: usize = 500;
599
600const DEDUP_THRESHOLD: f64 = 0.7;
602
603pub struct RememberFact {
604 workspace: PathBuf,
605 lock: Mutex<()>,
606}
607
608impl RememberFact {
609 pub fn new(workspace: impl Into<PathBuf>) -> Self {
610 Self {
611 workspace: workspace.into(),
612 lock: Mutex::new(()),
613 }
614 }
615}
616
617#[async_trait]
618impl Tool for RememberFact {
619 fn is_deferred(&self) -> bool {
620 true
621 }
622
623 fn spec(&self) -> ToolSpec {
624 ToolSpec {
625 name: "remember".into(),
626 description: "Save a fact to persistent memory. The fact will be available in future sessions via `recall` or injected into the system prompt. Supports deduplication (similar facts are merged) and scoping (workspace vs global).".into(),
627 parameters: json!({
628 "type": "object",
629 "properties": {
630 "text": {
631 "type": "string",
632 "description": "The fact text to remember"
633 },
634 "tags": {
635 "type": "array",
636 "items": {"type": "string"},
637 "description": "Optional tags for categorising the fact"
638 },
639 "source": {
640 "type": "string",
641 "description": "Optional source/provenance of the fact (e.g. 'user', 'agent')"
642 },
643 "scope": {
644 "type": "string",
645 "description": "Scope: 'workspace' (default) or 'global'",
646 "default": "workspace"
647 }
648 },
649 "required": ["text"]
650 }),
651 }
652 }
653
654 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
655 crate::tools::ToolSideEffect::Mutating
656 }
657
658 async fn execute(&self, arguments: Value) -> Result<String> {
659 let text = arguments["text"]
660 .as_str()
661 .ok_or_else(|| Error::BadToolArgs {
662 name: "remember".into(),
663 message: "missing required parameter: text".to_string(),
664 })?
665 .to_string();
666
667 let tags: Vec<String> = arguments["tags"]
668 .as_array()
669 .map(|arr| {
670 arr.iter()
671 .filter_map(|v| v.as_str().map(String::from))
672 .collect()
673 })
674 .unwrap_or_default();
675
676 let source: Option<String> = arguments["source"].as_str().map(String::from);
677
678 let scope = arguments["scope"]
679 .as_str()
680 .unwrap_or("workspace")
681 .to_string();
682
683 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
684 let _guard = self.lock.lock().unwrap();
685 let path = facts_path(&self.workspace, &scope);
686 let mut store = FactStore::load(&path)?;
687
688 let active: Vec<&Fact> = store.active_facts();
690 match find_duplicate(&active, &text, DEDUP_THRESHOLD) {
691 Some(DuplicateResult::SupersedeExisting(dup_id)) => {
692 store.soft_delete(&dup_id, &store.next_id());
693 let id = store.add(text, tags, source);
694 store.save(&path)?;
695 return Ok(format!("saved fact {id} (superseded {dup_id})"));
696 }
697 Some(DuplicateResult::KeepExisting(dup_id)) => {
698 if let Some(existing) = store.get_mut(&dup_id) {
699 existing.last_accessed = chrono_now_rfc3339();
700 existing.access_count += 1;
701 }
702 store.save(&path)?;
703 return Ok(format!("duplicate of {dup_id}, kept existing"));
704 }
705 None => {}
706 }
707
708 store.evict_to_cap(FACTS_CAP);
710 store.evict_to_cap(FACTS_CAP.saturating_sub(1));
712
713 let id = store.add(text, tags, source);
714 store.save(&path)?;
715 Ok(format!("saved fact {id}"))
716 }
717}
718
719pub struct RecallFact {
720 workspace: PathBuf,
721}
722
723impl RecallFact {
724 pub fn new(workspace: impl Into<PathBuf>) -> Self {
725 Self {
726 workspace: workspace.into(),
727 }
728 }
729}
730
731#[async_trait]
732impl Tool for RecallFact {
733 fn is_deferred(&self) -> bool {
734 true
735 }
736
737 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
738 crate::tools::ToolSideEffect::ReadOnly
739 }
740
741 fn spec(&self) -> ToolSpec {
742 ToolSpec {
743 name: "recall".into(),
744 description: "Search persistent memory for facts matching a query or tag. Returns up to `limit` results, sorted by relevance. Also supports scoping (workspace vs global).".into(),
745 parameters: json!({
746 "type": "object",
747 "properties": {
748 "query": {
749 "type": "string",
750 "description": "Search query for full-text search across fact text and tags"
751 },
752 "tag": {
753 "type": "string",
754 "description": "Exact tag to filter by"
755 },
756 "limit": {
757 "type": "integer",
758 "description": "Maximum number of results (default 10)",
759 "default": 10
760 },
761 "scope": {
762 "type": "string",
763 "description": "Scope: 'workspace' (default) or 'global'",
764 "default": "workspace"
765 }
766 }
767 }),
768 }
769 }
770
771 async fn execute(&self, arguments: Value) -> Result<String> {
772 let query = arguments["query"].as_str().unwrap_or("");
773 let tag = arguments["tag"].as_str();
774 let limit = arguments["limit"].as_i64().unwrap_or(10) as usize;
775 let scope = arguments["scope"].as_str().unwrap_or("workspace");
776
777 let path = facts_path(&self.workspace, scope);
778 let mut store = FactStore::load(&path)?;
779 let active: Vec<&Fact> = store.active_facts();
780
781 let results = if query.is_empty() && tag.is_none() {
782 let mut sorted: Vec<&Fact> = active;
784 sorted.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
785 sorted.truncate(limit);
786 sorted
787 .into_iter()
788 .map(|f| ScoredFact {
789 fact: f.clone(),
790 score: 0.0,
791 })
792 .collect()
793 } else {
794 search_facts(&active, query, tag, limit)
795 };
796
797 if results.is_empty() {
798 return Ok("no matching facts found".to_string());
799 }
800
801 for scored in &results {
803 if let Some(fact) = store.get_mut(&scored.fact.id) {
804 fact.last_accessed = chrono_now_rfc3339();
805 fact.access_count += 1;
806 }
807 }
808 store.save(&path)?;
809
810 let lines: Vec<String> = results
811 .iter()
812 .map(|sf| {
813 let tags_str = if sf.fact.tags.is_empty() {
814 String::new()
815 } else {
816 format!(" [{}]", sf.fact.tags.join(","))
817 };
818 let source_str = if let Some(ref src) = sf.fact.source {
819 format!(" (source: {})", src)
820 } else {
821 String::new()
822 };
823 format!("{}{}{} {}", sf.fact.id, tags_str, source_str, sf.fact.text)
824 })
825 .collect();
826
827 Ok(lines.join("\n"))
828 }
829}
830
831pub struct ForgetFact {
832 workspace: PathBuf,
833 lock: Mutex<()>,
834}
835
836impl ForgetFact {
837 pub fn new(workspace: impl Into<PathBuf>) -> Self {
838 Self {
839 workspace: workspace.into(),
840 lock: Mutex::new(()),
841 }
842 }
843}
844
845#[async_trait]
846impl Tool for ForgetFact {
847 fn is_deferred(&self) -> bool {
848 true
849 }
850
851 fn spec(&self) -> ToolSpec {
852 ToolSpec {
853 name: "forget".into(),
854 description: "Remove a fact from persistent memory by its ID (soft delete).".into(),
855 parameters: json!({
856 "type": "object",
857 "properties": {
858 "id": {
859 "type": "string",
860 "description": "The ID of the fact to remove (e.g. F3)"
861 },
862 "scope": {
863 "type": "string",
864 "description": "Scope: 'workspace' (default) or 'global'",
865 "default": "workspace"
866 }
867 },
868 "required": ["id"]
869 }),
870 }
871 }
872
873 async fn execute(&self, arguments: Value) -> Result<String> {
874 let id = arguments["id"]
875 .as_str()
876 .ok_or_else(|| Error::BadToolArgs {
877 name: "forget".into(),
878 message: "missing required parameter: id".to_string(),
879 })?
880 .to_string();
881
882 let scope = arguments["scope"].as_str().unwrap_or("workspace");
883
884 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
885 let _guard = self.lock.lock().unwrap();
886 let path = facts_path(&self.workspace, scope);
887 let mut store = FactStore::load(&path)?;
888
889 if store.get(&id).is_none() {
890 return Ok(format!("no such fact: {id}"));
891 }
892
893 store.soft_delete(&id, "__forgotten__");
894 store.save(&path)?;
895 Ok(format!("forgotten fact {id}"))
896 }
897}
898
899pub struct UpdateFact {
900 workspace: PathBuf,
901 lock: Mutex<()>,
902}
903
904impl UpdateFact {
905 pub fn new(workspace: impl Into<PathBuf>) -> Self {
906 Self {
907 workspace: workspace.into(),
908 lock: Mutex::new(()),
909 }
910 }
911}
912
913#[async_trait]
914impl Tool for UpdateFact {
915 fn is_deferred(&self) -> bool {
916 true
917 }
918
919 fn spec(&self) -> ToolSpec {
920 ToolSpec {
921 name: "update_fact".into(),
922 description: "Update an existing fact with new text. Creates a new version and links the old one as superseded.".into(),
923 parameters: json!({
924 "type": "object",
925 "properties": {
926 "id": {
927 "type": "string",
928 "description": "The ID of the fact to update (e.g. F3)"
929 },
930 "new_text": {
931 "type": "string",
932 "description": "The new text for the fact"
933 },
934 "scope": {
935 "type": "string",
936 "description": "Scope: 'workspace' (default) or 'global'",
937 "default": "workspace"
938 }
939 },
940 "required": ["id", "new_text"]
941 }),
942 }
943 }
944
945 async fn execute(&self, arguments: Value) -> Result<String> {
946 let id = arguments["id"]
947 .as_str()
948 .ok_or_else(|| Error::BadToolArgs {
949 name: "update_fact".into(),
950 message: "missing required parameter: id".to_string(),
951 })?
952 .to_string();
953
954 let new_text = arguments["new_text"]
955 .as_str()
956 .ok_or_else(|| Error::BadToolArgs {
957 name: "update_fact".into(),
958 message: "missing required parameter: new_text".to_string(),
959 })?
960 .to_string();
961
962 let scope = arguments["scope"].as_str().unwrap_or("workspace");
963
964 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
965 let _guard = self.lock.lock().unwrap();
966 let path = facts_path(&self.workspace, scope);
967 let mut store = FactStore::load(&path)?;
968
969 let existing = store.get(&id).ok_or_else(|| Error::BadToolArgs {
970 name: "update_fact".into(),
971 message: format!("no such fact: {id}"),
972 })?;
973
974 let new_id = store.next_id();
975 let tags = existing.tags.clone();
976 let source = existing.source.clone();
977
978 store.soft_delete(&id, &new_id);
980
981 let now = chrono_now_rfc3339();
983 store.facts.push(Fact {
984 id: new_id.clone(),
985 text: new_text,
986 tags,
987 source,
988 created_at: now.clone(),
989 last_accessed: now,
990 access_count: 0,
991 superseded_by: None,
992 });
993
994 store.save(&path)?;
995 Ok(format!("updated fact {id} -> {new_id}"))
996 }
997}
998
999#[cfg(test)]
1004mod tests {
1005 use super::*;
1006
1007 fn tmp_workspace() -> (tempfile::TempDir, PathBuf) {
1009 let tmp = tempfile::TempDir::new().unwrap();
1010 let ws = tmp.path().to_path_buf();
1011 (tmp, ws)
1012 }
1013
1014 #[test]
1015 fn test_a_remember_recall_roundtrip() {
1016 let (_tmp, ws) = tmp_workspace();
1017 let remember = RememberFact::new(&ws);
1018 let recall = RecallFact::new(&ws);
1019
1020 let result = tokio::runtime::Runtime::new()
1022 .unwrap()
1023 .block_on(remember.execute(json!({
1024 "text": "Rust uses the Result type for error handling",
1025 "tags": ["rust", "error-handling"],
1026 "source": "agent"
1027 })));
1028 assert!(result.is_ok());
1029 let msg = result.unwrap();
1030 assert!(msg.starts_with("saved fact F"), "got: {msg}");
1031
1032 let result = tokio::runtime::Runtime::new()
1034 .unwrap()
1035 .block_on(recall.execute(json!({"query": "Result type"})));
1036 assert!(result.is_ok());
1037 let output = result.unwrap();
1038 assert!(output.contains("F1"), "output: {output}");
1039 assert!(output.contains("Result type"), "output: {output}");
1040 assert!(output.contains("[rust,error-handling]"), "output: {output}");
1041 }
1042
1043 #[test]
1044 fn test_b_duplicate_detection() {
1045 let (_tmp, ws) = tmp_workspace();
1046 let remember = RememberFact::new(&ws);
1047
1048 tokio::runtime::Runtime::new()
1050 .unwrap()
1051 .block_on(remember.execute(json!({
1052 "text": "Rust uses the Result type for error handling",
1053 "tags": ["rust"]
1054 })))
1055 .unwrap();
1056
1057 let result = tokio::runtime::Runtime::new()
1059 .unwrap()
1060 .block_on(remember.execute(json!({
1061 "text": "Rust uses Result for error handling",
1062 "tags": ["rust"]
1063 })));
1064 assert!(result.is_ok());
1065 let msg = result.unwrap();
1066 assert!(msg.contains("duplicate of F1"), "got: {msg}");
1067 }
1068
1069 #[test]
1070 fn test_c_tag_filtering() {
1071 let (_tmp, ws) = tmp_workspace();
1072 let remember = RememberFact::new(&ws);
1073 let recall = RecallFact::new(&ws);
1074
1075 tokio::runtime::Runtime::new()
1076 .unwrap()
1077 .block_on(remember.execute(json!({
1078 "text": "Python uses exceptions for error handling",
1079 "tags": ["python"]
1080 })))
1081 .unwrap();
1082
1083 tokio::runtime::Runtime::new()
1084 .unwrap()
1085 .block_on(remember.execute(json!({
1086 "text": "Rust uses Result for error handling",
1087 "tags": ["rust"]
1088 })))
1089 .unwrap();
1090
1091 let result = tokio::runtime::Runtime::new()
1093 .unwrap()
1094 .block_on(recall.execute(json!({"tag": "rust"})));
1095 assert!(result.is_ok());
1096 let output = result.unwrap();
1097 assert!(output.contains("F2"), "output: {output}");
1098 assert!(!output.contains("F1"), "output: {output}");
1099 }
1100
1101 #[test]
1102 fn test_d_access_count_increments() {
1103 let (_tmp, ws) = tmp_workspace();
1104 let remember = RememberFact::new(&ws);
1105 let recall = RecallFact::new(&ws);
1106
1107 tokio::runtime::Runtime::new()
1108 .unwrap()
1109 .block_on(remember.execute(json!({
1110 "text": "Important fact",
1111 "tags": ["test"]
1112 })))
1113 .unwrap();
1114
1115 for _ in 0..3 {
1117 tokio::runtime::Runtime::new()
1118 .unwrap()
1119 .block_on(recall.execute(json!({"query": "Important"})))
1120 .unwrap();
1121 }
1122
1123 let path = facts_path(&ws, "workspace");
1125 let store = FactStore::load(&path).unwrap();
1126 let fact = store.get("F1").unwrap();
1127 assert_eq!(fact.access_count, 3, "access count should be 3");
1128 }
1129
1130 #[test]
1131 fn test_e_eviction_at_cap() {
1132 let (_tmp, ws) = tmp_workspace();
1133 let remember = RememberFact::new(&ws);
1134
1135 for i in 0..FACTS_CAP + 10 {
1137 tokio::runtime::Runtime::new()
1138 .unwrap()
1139 .block_on(remember.execute(json!({
1140 "text": format!("Fact number {}", i),
1141 "tags": ["test"]
1142 })))
1143 .unwrap();
1144 }
1145
1146 let path = facts_path(&ws, "workspace");
1148 let store = FactStore::load(&path).unwrap();
1149 let active = store.active_facts();
1150 assert!(
1151 active.len() <= FACTS_CAP,
1152 "active facts: {} > cap {}",
1153 active.len(),
1154 FACTS_CAP
1155 );
1156 }
1157
1158 #[test]
1159 fn test_f_forget_marks_superseded() {
1160 let (_tmp, ws) = tmp_workspace();
1161 let remember = RememberFact::new(&ws);
1162 let forget = ForgetFact::new(&ws);
1163
1164 tokio::runtime::Runtime::new()
1165 .unwrap()
1166 .block_on(remember.execute(json!({
1167 "text": "Something to forget",
1168 "tags": ["test"]
1169 })))
1170 .unwrap();
1171
1172 let result = tokio::runtime::Runtime::new()
1173 .unwrap()
1174 .block_on(forget.execute(json!({"id": "F1"})));
1175 assert!(result.is_ok());
1176 assert_eq!(result.unwrap(), "forgotten fact F1");
1177
1178 let path = facts_path(&ws, "workspace");
1180 let store = FactStore::load(&path).unwrap();
1181 let fact = store.get("F1").unwrap();
1182 assert_eq!(fact.superseded_by, Some("__forgotten__".to_string()));
1183 assert!(!fact.is_active());
1184 }
1185
1186 #[test]
1187 fn test_g_update_fact_creates_new_version() {
1188 let (_tmp, ws) = tmp_workspace();
1189 let remember = RememberFact::new(&ws);
1190 let update = UpdateFact::new(&ws);
1191
1192 tokio::runtime::Runtime::new()
1193 .unwrap()
1194 .block_on(remember.execute(json!({
1195 "text": "Old version of the fact",
1196 "tags": ["test"]
1197 })))
1198 .unwrap();
1199
1200 let result = tokio::runtime::Runtime::new()
1201 .unwrap()
1202 .block_on(update.execute(json!({
1203 "id": "F1",
1204 "new_text": "New improved version of the fact"
1205 })));
1206 assert!(result.is_ok());
1207 let msg = result.unwrap();
1208 assert!(msg.contains("F1 -> F2"), "got: {msg}");
1209
1210 let path = facts_path(&ws, "workspace");
1212 let store = FactStore::load(&path).unwrap();
1213 let old = store.get("F1").unwrap();
1214 assert_eq!(old.superseded_by, Some("F2".to_string()));
1215 let new_fact = store.get("F2").unwrap();
1216 assert_eq!(new_fact.text, "New improved version of the fact");
1217 assert!(new_fact.is_active());
1218 }
1219
1220 #[test]
1221 fn test_h_search_scoring() {
1222 let (_tmp, ws) = tmp_workspace();
1223 let remember = RememberFact::new(&ws);
1224
1225 tokio::runtime::Runtime::new()
1227 .unwrap()
1228 .block_on(remember.execute(json!({
1229 "text": "Rust is a systems programming language",
1230 "tags": ["rust"]
1231 })))
1232 .unwrap();
1233
1234 tokio::runtime::Runtime::new()
1235 .unwrap()
1236 .block_on(remember.execute(json!({
1237 "text": "Python is great for data science",
1238 "tags": ["python"]
1239 })))
1240 .unwrap();
1241
1242 tokio::runtime::Runtime::new()
1243 .unwrap()
1244 .block_on(remember.execute(json!({
1245 "text": "Rust has excellent performance characteristics",
1246 "tags": ["rust", "performance"]
1247 })))
1248 .unwrap();
1249
1250 let path = facts_path(&ws, "workspace");
1252 let store = FactStore::load(&path).unwrap();
1253 let active: Vec<&Fact> = store.active_facts();
1254 let results = search_facts(&active, "rust", None, 10);
1255
1256 assert_eq!(results.len(), 2, "should find 2 rust facts");
1257 for r in &results {
1259 assert!(
1260 r.fact.text.to_lowercase().contains("rust"),
1261 "fact: {}",
1262 r.fact.text
1263 );
1264 }
1265 }
1266
1267 #[test]
1268 fn test_i_scope_isolation() {
1269 let (_tmp, ws) = tmp_workspace();
1270
1271 let home = ws.join("home");
1277 std::fs::create_dir_all(&home).unwrap();
1278 let _g = crate::test_util::PinnedHome::new(&home);
1279
1280 let remember = RememberFact::new(&ws);
1281 let recall = RecallFact::new(&ws);
1282
1283 tokio::runtime::Runtime::new()
1285 .unwrap()
1286 .block_on(remember.execute(json!({
1287 "text": "Workspace-specific fact",
1288 "scope": "workspace"
1289 })))
1290 .unwrap();
1291
1292 tokio::runtime::Runtime::new()
1294 .unwrap()
1295 .block_on(remember.execute(json!({
1296 "text": "Global fact",
1297 "scope": "global"
1298 })))
1299 .unwrap();
1300
1301 let result = tokio::runtime::Runtime::new()
1303 .unwrap()
1304 .block_on(recall.execute(json!({"scope": "workspace", "query": "fact"})));
1305 assert!(result.is_ok());
1306 let output = result.unwrap();
1307 assert!(output.contains("Workspace-specific"), "output: {output}");
1308 assert!(!output.contains("Global"), "output: {output}");
1309
1310 let result = tokio::runtime::Runtime::new()
1312 .unwrap()
1313 .block_on(recall.execute(json!({"scope": "global", "query": "fact"})));
1314 assert!(result.is_ok());
1315 let output = result.unwrap();
1316 assert!(output.contains("Global"), "output: {output}");
1317 assert!(!output.contains("Workspace-specific"), "output: {output}");
1318 }
1319
1320 #[test]
1321 fn test_j_tokenize_and_stop_words() {
1322 let tokens = tokenize("Hello, World! This is a test.");
1323 assert!(tokens.contains(&"hello".to_string()));
1324 assert!(tokens.contains(&"world".to_string()));
1325 assert!(tokens.contains(&"test".to_string()));
1326 assert!(tokens.contains(&"this".to_string()));
1328 assert!(
1331 !tokens.iter().any(|t| t.is_empty()),
1332 "tokenize must drop empty fragments: {tokens:?}"
1333 );
1334 assert_eq!(
1335 tokenize("a,b"),
1336 vec!["a".to_string(), "b".to_string()],
1337 "comma alone must split like whitespace"
1338 );
1339 assert_eq!(
1340 tokenize("a b"),
1341 vec!["a".to_string(), "b".to_string()],
1342 "whitespace alone must split"
1343 );
1344 }
1345
1346 #[test]
1347 fn test_k_jaccard_similarity() {
1348 let sim = jaccard_similarity(
1349 "Rust uses Result for error handling",
1350 "Rust uses Result for error handling",
1351 );
1352 assert!(
1353 (sim - 1.0).abs() < 0.01,
1354 "identical texts should have sim=1, got {sim}"
1355 );
1356
1357 let sim = jaccard_similarity("Rust uses Result", "Python uses exceptions");
1358 assert!(sim < 0.5, "different texts should have low sim, got {sim}");
1359
1360 let sim = jaccard_similarity("", "");
1361 assert!(
1362 (sim - 1.0).abs() < 0.01,
1363 "empty strings should have sim=1, got {sim}"
1364 );
1365 let sim = jaccard_similarity("", "hello world");
1368 assert!(
1369 (sim - 0.0).abs() < 0.01,
1370 "empty vs non-empty must be 0, got {sim}"
1371 );
1372 }
1373
1374 #[test]
1375 fn find_duplicate_equal_length_keeps_existing() {
1376 let mut store = fresh_fact_store();
1379 store.add("abcd".into(), vec![], None);
1380 let active = store.active_facts();
1381 match find_duplicate(&active, "wxyz", 0.0) {
1382 Some(DuplicateResult::KeepExisting(id)) => assert_eq!(id, "F1"),
1383 other => panic!("equal-length duplicate must KeepExisting, got {other:?}"),
1384 }
1385 match find_duplicate(&active, "abcde", 0.0) {
1386 Some(DuplicateResult::SupersedeExisting(id)) => assert_eq!(id, "F1"),
1387 other => panic!("longer text must SupersedeExisting, got {other:?}"),
1388 }
1389 }
1390
1391 #[test]
1392 fn test_l_rfc3339_to_secs() {
1393 assert!(
1395 rfc3339_to_secs("1970-01-01").is_none(),
1396 "too-short timestamp must be None"
1397 );
1398 assert!(rfc3339_to_secs("").is_none());
1399 let secs = rfc3339_to_secs("2026-05-25T12:00:00Z").unwrap();
1400 assert!(secs > 0.0, "should parse to positive seconds");
1401 assert_eq!(rfc3339_to_secs("1970-01-01T00:00:00Z"), Some(0.0));
1403 assert_eq!(rfc3339_to_secs("1970-01-01T01:00:00Z"), Some(3600.0));
1405 assert_eq!(rfc3339_to_secs("1970-01-01T00:01:00Z"), Some(60.0));
1407 assert_eq!(rfc3339_to_secs("1970-01-01T00:02:00Z"), Some(120.0));
1408 assert_eq!(rfc3339_to_secs("1970-01-01T00:00:05Z"), Some(5.0));
1410 assert_eq!(rfc3339_to_secs("1970-01-02T00:00:00Z"), Some(86400.0));
1412 }
1413
1414 #[test]
1415 fn is_leap_known_years() {
1416 assert!(is_leap(1972), "1972 is leap");
1417 assert!(!is_leap(1970), "1970 is not leap");
1418 assert!(!is_leap(1900), "1900 is century non-leap");
1419 assert!(is_leap(2000), "2000 is 400-year leap");
1420 assert!(is_leap(2024));
1422 assert!(!is_leap(2023));
1423 }
1424
1425 #[test]
1426 fn days_to_date_year_boundary_uses_strict_less() {
1427 assert_eq!(days_to_date(0), (1970, 1, 1));
1431 assert_eq!(days_to_date(364), (1970, 12, 31));
1432 assert_eq!(days_to_date(365), (1971, 1, 1));
1433 assert_eq!(days_to_date(30), (1970, 1, 31));
1436 assert_eq!(days_to_date(31), (1970, 2, 1));
1437 }
1438
1439 #[test]
1440 fn days_since_epoch_accumulates_months() {
1441 assert_eq!(days_since_epoch(1970, 1, 1), 0);
1443 assert_eq!(days_since_epoch(1970, 2, 1), 31);
1444 assert_eq!(days_since_epoch(1971, 1, 1), 365);
1445 assert_eq!(days_since_epoch(1972, 3, 1), 365 + 365 + 31 + 29);
1447 }
1448
1449 #[test]
1450 fn test_m_facts_summary_empty() {
1451 let (_tmp, ws) = tmp_workspace();
1452 let fake_home = _tmp.path().join("home");
1456 std::fs::create_dir_all(&fake_home).unwrap();
1457 let _pin = crate::test_util::PinnedHome::new(&fake_home);
1458 let summary = facts_summary(&ws, 5);
1459 assert_eq!(summary, "");
1460 }
1461
1462 #[test]
1463 fn test_n_facts_summary_with_facts() {
1464 let (_tmp, ws) = tmp_workspace();
1465 let remember = RememberFact::new(&ws);
1466
1467 tokio::runtime::Runtime::new()
1468 .unwrap()
1469 .block_on(remember.execute(json!({
1470 "text": "First fact",
1471 "tags": ["a"]
1472 })))
1473 .unwrap();
1474
1475 tokio::runtime::Runtime::new()
1476 .unwrap()
1477 .block_on(remember.execute(json!({
1478 "text": "Second fact",
1479 "tags": ["b"]
1480 })))
1481 .unwrap();
1482
1483 let summary = facts_summary(&ws, 5);
1484 assert!(!summary.is_empty());
1485 assert!(summary.contains("F1"));
1486 assert!(summary.contains("F2"));
1487 assert!(summary.contains("First fact"));
1488 assert!(summary.contains("Second fact"));
1489 }
1490
1491 #[test]
1492 fn test_o_facts_summary_truncates_long_text() {
1493 let (_tmp, ws) = tmp_workspace();
1495 let remember = RememberFact::new(&ws);
1496
1497 let long_text = "A".repeat(200); tokio::runtime::Runtime::new()
1499 .unwrap()
1500 .block_on(remember.execute(json!({"text": &long_text})))
1501 .unwrap();
1502
1503 let summary = facts_summary(&ws, 5);
1504 assert!(
1505 !summary.contains(&long_text),
1506 "fact text > 120 chars must be truncated in summary"
1507 );
1508 assert!(
1509 summary.contains("..."),
1510 "truncated fact must end with '...': {summary}"
1511 );
1512 }
1513
1514 #[test]
1515 fn facts_summary_exact_120_chars_not_truncated() {
1516 let (_tmp, ws) = tmp_workspace();
1518 let remember = RememberFact::new(&ws);
1519 let exact = "B".repeat(120);
1520 tokio::runtime::Runtime::new()
1521 .unwrap()
1522 .block_on(remember.execute(json!({"text": &exact})))
1523 .unwrap();
1524 let summary = facts_summary(&ws, 5);
1525 assert!(
1526 summary.contains(&exact),
1527 "exactly 120 chars must not be truncated: {summary}"
1528 );
1529 assert!(
1530 !summary.contains("BBB..."),
1531 "exact-120 text must not gain ellipsis"
1532 );
1533 }
1534
1535 #[test]
1536 fn fact_tools_are_deferred() {
1537 let (_tmp, ws) = tmp_workspace();
1538 assert!(RememberFact::new(&ws).is_deferred());
1539 assert!(RecallFact::new(&ws).is_deferred());
1540 assert!(ForgetFact::new(&ws).is_deferred());
1541 assert!(UpdateFact::new(&ws).is_deferred());
1542 }
1543
1544 #[test]
1545 fn remember_duplicate_keep_increments_access_count() {
1546 let (_tmp, ws) = tmp_workspace();
1548 let remember = RememberFact::new(&ws);
1549 let rt = tokio::runtime::Runtime::new().unwrap();
1550 rt.block_on(remember.execute(json!({"text": "same fact text"})))
1551 .unwrap();
1552 let msg = rt
1554 .block_on(remember.execute(json!({"text": "same fact text"})))
1555 .unwrap();
1556 assert!(
1557 msg.contains("duplicate of F1"),
1558 "identical text must KeepExisting: {msg}"
1559 );
1560 let path = facts_path(&ws, "workspace");
1561 let store = FactStore::load(&path).unwrap();
1562 let fact = store.get("F1").expect("F1");
1563 assert_eq!(
1564 fact.access_count, 1,
1565 "KeepExisting must bump access_count by 1, got {}",
1566 fact.access_count
1567 );
1568 rt.block_on(remember.execute(json!({"text": "same fact text"})))
1570 .unwrap();
1571 let store = FactStore::load(&path).unwrap();
1572 assert_eq!(store.get("F1").unwrap().access_count, 2);
1573 }
1574
1575 #[test]
1576 fn test_p_facts_summary_limit_caps_output() {
1577 let (_tmp, ws) = tmp_workspace();
1579 let fake_home = _tmp.path().join("home");
1582 std::fs::create_dir_all(&fake_home).unwrap();
1583 let _pin = crate::test_util::PinnedHome::new(&fake_home);
1584
1585 let remember = RememberFact::new(&ws);
1586 let rt = tokio::runtime::Runtime::new().unwrap();
1587
1588 for i in 1..=5 {
1589 rt.block_on(remember.execute(json!({"text": format!("Fact number {i}")})))
1590 .unwrap();
1591 }
1592
1593 let summary = facts_summary(&ws, 2);
1595 let count = summary.matches("Fact number").count();
1597 assert_eq!(
1598 count, 2,
1599 "limit=2 must restrict output to 2 facts, got: {summary}"
1600 );
1601 }
1602
1603 fn fresh_fact_store() -> FactStore {
1606 FactStore::default()
1607 }
1608
1609 #[test]
1612 fn fact_is_active_when_not_superseded() {
1613 let fact = Fact {
1614 id: "F1".into(),
1615 text: "test".into(),
1616 tags: vec![],
1617 source: None,
1618 created_at: "2026-01-01T00:00:00Z".into(),
1619 last_accessed: "2026-01-01T00:00:00Z".into(),
1620 access_count: 0,
1621 superseded_by: None,
1622 };
1623 assert!(
1624 fact.is_active(),
1625 "fact without superseded_by must be active"
1626 );
1627 }
1628
1629 #[test]
1630 fn fact_is_not_active_when_superseded() {
1631 let fact = Fact {
1632 id: "F1".into(),
1633 text: "old".into(),
1634 tags: vec![],
1635 source: None,
1636 created_at: "2026-01-01T00:00:00Z".into(),
1637 last_accessed: "2026-01-01T00:00:00Z".into(),
1638 access_count: 0,
1639 superseded_by: Some("F2".into()),
1640 };
1641 assert!(
1642 !fact.is_active(),
1643 "fact with superseded_by must NOT be active"
1644 );
1645 }
1646
1647 #[test]
1650 fn fact_store_load_missing_file_returns_empty() {
1651 let store = FactStore::load(std::path::Path::new("/nonexistent/path/facts.jsonl"))
1652 .expect("missing file must return empty store");
1653 assert!(store.facts.is_empty());
1654 }
1655
1656 #[test]
1657 fn fact_store_load_parses_jsonl() {
1658 let tmp = tempfile::NamedTempFile::new().unwrap();
1659 let fact_json = r#"{"id":"F1","text":"hello","tags":["t1"],"created_at":"2026-01-01T00:00:00Z","last_accessed":"2026-01-01T00:00:00Z","access_count":0}"#;
1660 std::fs::write(tmp.path(), fact_json).unwrap();
1661 let store = FactStore::load(tmp.path()).expect("must parse valid JSONL");
1662 assert_eq!(store.facts.len(), 1);
1663 assert_eq!(store.facts[0].id, "F1");
1664 }
1665
1666 #[test]
1669 fn fact_store_save_and_reload() {
1670 let tmp = tempfile::NamedTempFile::new().unwrap();
1671 let mut store = fresh_fact_store();
1672 store.add("round-trip text".into(), vec!["tag".into()], None);
1673 store.save(tmp.path()).expect("save must succeed");
1674
1675 let loaded = FactStore::load(tmp.path()).expect("reload must succeed");
1676 assert_eq!(loaded.facts.len(), 1);
1677 assert_eq!(loaded.facts[0].text, "round-trip text");
1678 }
1679
1680 #[test]
1683 fn fact_store_next_id_empty_store_is_f1() {
1684 assert_eq!(fresh_fact_store().next_id(), "F1");
1685 }
1686
1687 #[test]
1688 fn fact_store_next_id_increments() {
1689 let mut store = fresh_fact_store();
1690 store.add("a".into(), vec![], None);
1691 store.add("b".into(), vec![], None);
1692 assert_eq!(store.next_id(), "F3");
1693 }
1694
1695 #[test]
1698 fn fact_store_add_returns_correct_id() {
1699 let mut store = fresh_fact_store();
1700 let id = store.add("my fact".into(), vec!["tag".into()], Some("agent".into()));
1701 assert_eq!(id, "F1");
1702 assert_eq!(store.facts[0].id, "F1");
1703 assert_eq!(store.facts[0].text, "my fact");
1704 assert_eq!(store.facts[0].source, Some("agent".into()));
1705 }
1706
1707 #[test]
1710 fn fact_store_get_finds_by_id() {
1711 let mut store = fresh_fact_store();
1712 store.add("x".into(), vec![], None);
1713 assert!(store.get("F1").is_some(), "must find F1");
1714 assert!(store.get("F99").is_none(), "must not find non-existent");
1715 }
1716
1717 #[test]
1720 fn fact_store_soft_delete_returns_true_when_found() {
1721 let mut store = fresh_fact_store();
1722 store.add("old".into(), vec![], None);
1723 let deleted = store.soft_delete("F1", "F2");
1724 assert!(deleted, "soft_delete must return true for existing fact");
1725 assert_eq!(store.facts[0].superseded_by.as_deref(), Some("F2"));
1726 }
1727
1728 #[test]
1729 fn fact_store_soft_delete_returns_false_when_not_found() {
1730 let mut store = fresh_fact_store();
1731 assert!(
1732 !store.soft_delete("F99", "F100"),
1733 "soft_delete must return false for non-existent id"
1734 );
1735 }
1736
1737 #[test]
1740 fn fact_store_active_facts_excludes_superseded() {
1741 let mut store = fresh_fact_store();
1742 store.add("active".into(), vec![], None);
1743 store.add("old".into(), vec![], None);
1744 store.soft_delete("F2", "F1");
1745
1746 let active = store.active_facts();
1747 assert_eq!(active.len(), 1, "only non-superseded facts must be active");
1748 assert_eq!(active[0].id, "F1");
1749 }
1750
1751 #[test]
1752 fn fact_store_active_facts_empty_on_all_superseded() {
1753 let mut store = fresh_fact_store();
1754 store.add("x".into(), vec![], None);
1755 store.soft_delete("F1", "F2");
1756 assert!(store.active_facts().is_empty());
1757 }
1758
1759 #[test]
1762 fn fact_store_evict_to_cap_removes_excess() {
1763 let mut store = fresh_fact_store();
1764 for i in 0..5 {
1765 store.add(format!("fact {i}"), vec![], None);
1766 }
1767 assert_eq!(store.active_facts().len(), 5);
1768 let evicted = store.evict_to_cap(3);
1769 assert_eq!(evicted, 2, "must evict exactly 2 facts to reach cap of 3");
1770 assert_eq!(store.active_facts().len(), 3);
1771 }
1772
1773 #[test]
1774 fn fact_store_evict_to_cap_noop_when_under_cap() {
1775 let mut store = fresh_fact_store();
1776 store.add("a".into(), vec![], None);
1777 store.add("b".into(), vec![], None);
1778 let evicted = store.evict_to_cap(5);
1779 assert_eq!(evicted, 0, "must not evict when under cap");
1780 assert_eq!(store.active_facts().len(), 2);
1781 }
1782
1783 #[test]
1784 fn fact_store_evict_at_exact_cap_is_noop() {
1785 let mut store = fresh_fact_store();
1789 for i in 0..3 {
1790 store.add(format!("fact {i}"), vec![], None);
1791 }
1792 assert_eq!(store.active_facts().len(), 3);
1793 let evicted = store.evict_to_cap(3);
1794 assert_eq!(evicted, 0, "evict_to_cap at exact cap must evict 0 facts");
1795 assert_eq!(
1796 store.active_facts().len(),
1797 3,
1798 "all 3 facts must remain active"
1799 );
1800 }
1801
1802 #[test]
1803 fn fact_store_evict_marks_removed_with_sentinel() {
1804 let mut store = fresh_fact_store();
1806 for i in 0..3 {
1807 store.add(format!("fact {i}"), vec![], None);
1808 }
1809 store.evict_to_cap(1); let evicted_count = store
1813 .facts
1814 .iter()
1815 .filter(|f| f.superseded_by.as_deref() == Some("__evicted__"))
1816 .count();
1817 assert_eq!(
1818 evicted_count, 2,
1819 "evicted facts must have superseded_by = __evicted__"
1820 );
1821 }
1822
1823 #[test]
1824 fn fact_store_evict_prefers_staler_facts() {
1825 let mut store = fresh_fact_store();
1828 store.add("oldest".into(), vec![], None);
1829 store.add("middle".into(), vec![], None);
1830 store.add("newest".into(), vec![], None);
1831 store.facts[0].last_accessed = "2020-01-01T00:00:00Z".into();
1832 store.facts[1].last_accessed = "2024-06-01T00:00:00Z".into();
1833 store.facts[2].last_accessed = "2026-07-01T00:00:00Z".into();
1834 for f in &mut store.facts {
1835 f.access_count = 1;
1836 }
1837 let evicted = store.evict_to_cap(1);
1838 assert_eq!(evicted, 2);
1839 assert!(!store.facts[0].is_active(), "oldest must be evicted first");
1840 assert!(!store.facts[1].is_active(), "middle must be evicted second");
1841 assert!(
1842 store.facts[2].is_active(),
1843 "newest must survive: {:?}",
1844 store.facts[2].superseded_by
1845 );
1846 }
1847}