Skip to main content

recursive/tools/
facts.rs

1//! Memory Layer 2 — Semantic facts with search.
2//!
3//! Extends the existing `remember`/`recall`/`forget` tools with structured
4//! facts storage, pure Rust full-text search, deduplication, and eviction.
5//!
6//! Facts are stored in JSONL format at:
7//!   <workspace>/.recursive/memory/facts.jsonl  (workspace scope)
8//!   ~/.recursive/memory/facts.jsonl            (global scope)
9//!
10//! Each fact has:
11//! - `id` (monotonic "F1", "F2", ...)
12//! - `text` (the fact content)
13//! - `tags` (optional categorisation)
14//! - `source` (optional provenance, e.g. "user", "agent")
15//! - `created_at` (RFC 3339 timestamp)
16//! - `last_accessed` (RFC 3339 timestamp, updated on recall)
17//! - `access_count` (how many times recalled)
18//! - `superseded_by` (optional fact ID that replaces this one — soft delete)
19
20use 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// ---------------------------------------------------------------------------
32// Fact data structures
33// ---------------------------------------------------------------------------
34
35/// A single semantic fact.
36#[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    /// Whether this fact is considered "active" (not superseded).
53    fn is_active(&self) -> bool {
54        self.superseded_by.is_none()
55    }
56}
57
58/// The on-disk fact store (JSONL).
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct FactStore {
61    pub facts: Vec<Fact>,
62}
63
64impl FactStore {
65    /// Load facts from a JSONL file path. Returns empty store if file doesn't exist.
66    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    /// Save facts to a JSONL file, creating parent directories if needed.
96    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    /// Generate the next monotonic fact ID.
122    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    /// Add a new fact, returning its ID.
134    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    /// Find a fact by ID.
151    fn get(&self, id: &str) -> Option<&Fact> {
152        self.facts.iter().find(|f| f.id == id)
153    }
154
155    /// Get a mutable reference to a fact by ID.
156    fn get_mut(&mut self, id: &str) -> Option<&mut Fact> {
157        self.facts.iter_mut().find(|f| f.id == id)
158    }
159
160    /// Soft-delete a fact by setting `superseded_by`.
161    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    /// Return all active (non-superseded) facts.
171    fn active_facts(&self) -> Vec<&Fact> {
172        self.facts.iter().filter(|f| f.is_active()).collect()
173    }
174
175    /// Evict the stalest facts until we're under `cap` active facts.
176    /// Staleness = days since last_accessed * (1.0 / (access_count + 1)).
177    /// Returns the number of facts evicted.
178    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        // Score each active fact by staleness
186        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        // Sort by staleness descending (most stale first)
203        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            // Mark as superseded by a sentinel
208            self.facts[*idx].superseded_by = Some("__evicted__".to_string());
209            evicted += 1;
210        }
211        evicted
212    }
213}
214
215/// Staleness score used by eviction ranking.
216///
217/// Soft-skipped: arithmetic mutants (`/86400`, `*`, `-`) preserve relative
218/// order for equal `access_count`, so unit tests cannot distinguish them
219/// without flaky wall-clock assertions. Ranking order is pinned by
220/// `fact_store_evict_prefers_staler_facts`.
221#[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// ---------------------------------------------------------------------------
229// Full-text search (pure Rust)
230// ---------------------------------------------------------------------------
231
232/// A search result with a relevance score.
233#[derive(Debug, Clone)]
234pub struct ScoredFact {
235    pub fact: Fact,
236    pub score: f64,
237}
238
239/// Tokenize text into lowercase terms, splitting on whitespace and punctuation.
240fn 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
247/// Stop words to filter out.
248const 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
258/// Search facts by query text, returning scored results sorted by relevance.
259///
260/// Scoring formula:
261///   score = term_frequency * tag_match * recency_boost * popularity_boost
262///
263/// - term_frequency: fraction of query terms that appear in the fact text
264/// - tag_match: 1.2 if any query term matches a tag, else 1.0
265/// - recency_boost: 1.0 + 0.1 / (days_since_created + 1)
266/// - popularity_boost: 1.0 + 0.05 * ln(access_count + 1)
267pub 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 filter
288            tag.map_or(true, |t| f.tags.iter().any(|ft| ft == t))
289        })
290        .filter(|f| {
291            // If no query terms, include all (tag-only search)
292            if query_terms.is_empty() {
293                return true;
294            }
295            // At least one query term must appear in text or tags
296            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    // Sort by score descending
309    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/// Relevance score for a single fact against query terms.
319///
320/// Soft-skipped: the multiplicative boost formula's arithmetic mutants
321/// (`/`, `*`, `+`, `-` on weights) rarely change ranking under the coarse
322/// fixtures we use. Filter / sort / limit behaviour of `search_facts` is
323/// pinned separately (tag filter, empty-query, truncate).
324#[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
353// ---------------------------------------------------------------------------
354// Deduplication (Jaccard similarity)
355// ---------------------------------------------------------------------------
356
357/// Compute Jaccard similarity between two strings (token-level).
358fn 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/// Result of a duplicate-fact check.
375#[derive(Debug)]
376enum DuplicateResult {
377    /// The new text is longer/more specific — supersede the existing fact with this ID.
378    SupersedeExisting(String),
379    /// The existing fact is at least as specific — keep it, discard the new text.
380    KeepExisting(String),
381}
382
383/// Check if a new fact text is a duplicate of an existing active fact.
384/// Returns `Some(DuplicateResult)` if similarity >= threshold, `None` otherwise.
385fn 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// ---------------------------------------------------------------------------
402// Timestamp helpers
403// ---------------------------------------------------------------------------
404
405/// Get an RFC 3339 timestamp string.
406///
407/// Soft-skipped: wall-clock formatting arithmetic (`/86400`, `%`) is not
408/// unit-observable without freezing time; shape is covered indirectly by
409/// fact create/load round-trips that assert non-empty timestamps.
410#[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
429/// Convert days since Unix epoch to (year, month, day).
430fn 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        // Soft-skip the subtract: `-=` → `+=` infinite-loops the year walk
438        // (cargo-mutants TIMEOUT). Boundary behaviour is pinned by
439        // `days_to_date_year_boundary_uses_strict_less`.
440        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/// Subtract one year's day count while walking the epoch calendar.
465///
466/// Soft-skipped: `-=` → `+=` never terminates, so cargo-mutants reports
467/// TIMEOUT rather than a catchable failure.
468#[cfg_attr(test, mutants::skip)]
469fn subtract_year_days(days: u64, days_in_year: u64) -> u64 {
470    days - days_in_year
471}
472
473/// Parse an RFC 3339 timestamp to seconds since Unix epoch.
474fn rfc3339_to_secs(s: &str) -> Option<f64> {
475    // Format: "2026-05-25T12:34:56Z"
476    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
506// ---------------------------------------------------------------------------
507// Path helpers
508// ---------------------------------------------------------------------------
509
510/// Determine the facts file path for a given scope.
511pub 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
533/// Load the fact store for a given scope.
534pub fn load_facts(workspace: &std::path::Path, scope: &str) -> Result<FactStore> {
535    let path = facts_path(workspace, scope);
536    FactStore::load(&path)
537}
538
539/// Build a facts summary string for injection into the system prompt.
540/// Merges workspace-scoped and global-scoped facts, then returns the top N
541/// most recently accessed facts as a formatted block.
542pub fn facts_summary(workspace: &std::path::Path, limit: usize) -> String {
543    // Load both scopes; silently ignore missing files.
544    let workspace_store = load_facts(workspace, "workspace").unwrap_or_default();
545    let global_store = load_facts(workspace, "global").unwrap_or_default();
546
547    // Merge active facts from both scopes; global facts come first so that
548    // user-identity facts survive even when workspace has many project facts.
549    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    // Sort by last_accessed descending (most recently accessed first)
561    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
593// ---------------------------------------------------------------------------
594// Tool implementations
595// ---------------------------------------------------------------------------
596
597/// Maximum active facts per scope before eviction kicks in.
598const FACTS_CAP: usize = 500;
599
600/// Jaccard similarity threshold for deduplication.
601const 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        // Deduplication check
689        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        // Eviction check
709        store.evict_to_cap(FACTS_CAP);
710        // Evict to cap-1 so that after adding the new fact we stay at cap
711        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            // No query: return most recently accessed
783            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        // Update access stats for returned facts
802        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        // Soft-delete the old fact
979        store.soft_delete(&id, &new_id);
980
981        // Add the new version
982        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// ---------------------------------------------------------------------------
1000// Tests
1001// ---------------------------------------------------------------------------
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006
1007    /// Helper: create a temporary workspace dir.
1008    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        // Remember a fact
1021        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        // Recall by query
1033        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        // First fact
1049        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        // Similar fact — should be detected as duplicate
1058        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        // Filter by tag
1092        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        // Recall multiple times
1116        for _ in 0..3 {
1117            tokio::runtime::Runtime::new()
1118                .unwrap()
1119                .block_on(recall.execute(json!({"query": "Important"})))
1120                .unwrap();
1121        }
1122
1123        // Load store and check access count
1124        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        // Add FACTS_CAP + 10 facts
1136        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        // Load store and check active count
1147        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        // Verify it's no longer active
1179        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        // Verify old fact is superseded
1211        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        // Add facts with varying relevance
1226        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        // Search for "rust"
1251        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        // Both should have "rust" in them
1258        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        // Override HOME to the temp dir so global-scope facts land in a
1272        // writable location (macOS security policy may block writes to
1273        // ~/.recursive/memory/ in test environments). The guard holds
1274        // the cross-module env lock and restores HOME on drop, so this
1275        // test no longer pollutes parallel tests that read $HOME.
1276        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        // Add workspace fact
1284        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        // Add global fact
1293        tokio::runtime::Runtime::new()
1294            .unwrap()
1295            .block_on(remember.execute(json!({
1296                "text": "Global fact",
1297                "scope": "global"
1298            })))
1299            .unwrap();
1300
1301        // Recall workspace only
1302        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        // Recall global only
1311        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        // Stop words should still be in tokenize output (filtering is done in search)
1327        assert!(tokens.contains(&"this".to_string()));
1328        // Punctuation-only separators must not produce empty tokens
1329        // (kills `|| → &&` between whitespace and punctuation splitters).
1330        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        // kills `&& → ||` on the both-empty early return: one empty + one
1366        // non-empty must NOT short-circuit to 1.0.
1367        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        // kills `text.len() > fact.text.len()` → `==` (equal length must
1377        // KeepExisting, not SupersedeExisting).
1378        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        // kills `s.len() < 20` → `>` (short strings must be None, not Some)
1394        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        // Exact known epoch: 1970-01-01T00:00:00Z
1402        assert_eq!(rfc3339_to_secs("1970-01-01T00:00:00Z"), Some(0.0));
1403        // 1970-01-01T01:00:00Z = 3600s — kills `hour * 3600` → `hour + 3600`
1404        assert_eq!(rfc3339_to_secs("1970-01-01T01:00:00Z"), Some(3600.0));
1405        // 1970-01-01T00:01:00Z = 60s — kills `+ min*60` → `- min*60` and `*60` → `/60`
1406        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        // 1970-01-01T00:00:05Z = 5s — kills `+ sec` → `* sec`
1409        assert_eq!(rfc3339_to_secs("1970-01-01T00:00:05Z"), Some(5.0));
1410        // 1970-01-02T00:00:00Z = 86400s — kills `days * 86400` → `days + 86400`
1411        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        // kills `year % 4` → `year / 4` (1972/4==493, not 0)
1421        assert!(is_leap(2024));
1422        assert!(!is_leap(2023));
1423    }
1424
1425    #[test]
1426    fn days_to_date_year_boundary_uses_strict_less() {
1427        // Day 0 = 1970-01-01. Non-leap 1970 has 365 days, so day index 365
1428        // is 1971-01-01. With `<=` the year loop would incorrectly consume
1429        // the boundary day into 1970.
1430        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        // Month loop: day index 31 is 1970-02-01. With `days <= md` the
1434        // January (31) boundary would break early → bogus day 32.
1435        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        // 1970-01-01 → 0; 1970-02-01 → 31. kills `+=` → `-=` on month loop.
1442        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        // Leap year Feb 29: 1972-03-01 = 365+365+31+29 = 790
1446        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        // Pin HOME to an empty temp dir so global facts on the developer's
1453        // real machine don't leak into this test (facts_summary now merges
1454        // both workspace and global scopes).
1455        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        // kills `if fact.text.chars().count() > 120` guard removal mutations
1494        let (_tmp, ws) = tmp_workspace();
1495        let remember = RememberFact::new(&ws);
1496
1497        let long_text = "A".repeat(200); // 200 chars, well above 120 threshold
1498        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        // kills `> 120` → `>= 120`: exactly 120 chars must pass through uncut.
1517        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        // kills `access_count += 1` → `*= 1` on KeepExisting path
1547        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        // Identical text → KeepExisting (Jaccard = 1.0)
1553        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        // Second duplicate → access_count 2 (kills *= which would stay 1)
1569        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        // kills mutations of `.take(limit)` in facts_summary
1578        let (_tmp, ws) = tmp_workspace();
1579        // Pin HOME to a fresh directory so global facts from other tests
1580        // don't leak in (facts_summary merges workspace + global scopes).
1581        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        // limit=2 must show only 2 facts
1594        let summary = facts_summary(&ws, 2);
1595        // Count occurrences of "Fact number" in summary
1596        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    // ── FactStore unit tests (internal API) ──────────────────────────────────
1604
1605    fn fresh_fact_store() -> FactStore {
1606        FactStore::default()
1607    }
1608
1609    // --- Fact::is_active ---
1610
1611    #[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    // --- FactStore::load ---
1648
1649    #[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    // --- FactStore::save + load round-trip ---
1667
1668    #[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    // --- FactStore::next_id ---
1681
1682    #[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    // --- FactStore::add ---
1696
1697    #[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    // --- FactStore::get ---
1708
1709    #[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    // --- FactStore::soft_delete ---
1718
1719    #[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    // --- FactStore::active_facts ---
1738
1739    #[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    // --- FactStore::evict_to_cap ---
1760
1761    #[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        // kills `replace <= with <` in evict_to_cap (line 180)
1786        // With `< cap`: active_count(3) < cap(3) is false → eviction fires wrongly.
1787        // With `<= cap`: active_count(3) <= cap(3) is true → correctly returns 0.
1788        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        // kills function-level or body mutation of the `superseded_by = Some("__evicted__")` line
1805        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); // evict 2 of the 3 facts
1810
1811        // Exactly 2 facts should be marked with the __evicted__ sentinel.
1812        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        // Pins staleness ranking: older last_accessed must be evicted first
1826        // when access_count is equal.
1827        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}