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            message: format!("failed to read facts file: {e}"),
73        })?;
74        let mut facts = Vec::new();
75        for (i, line) in raw.lines().enumerate() {
76            let line = line.trim();
77            if line.is_empty() {
78                continue;
79            }
80            match serde_json::from_str::<Fact>(line) {
81                Ok(fact) => facts.push(fact),
82                Err(e) => {
83                    return Err(Error::Tool {
84                        name: "facts".into(),
85                        message: format!("malformed fact at line {}: {e}", i + 1),
86                    });
87                }
88            }
89        }
90        Ok(Self { facts })
91    }
92
93    /// Save facts to a JSONL file, creating parent directories if needed.
94    fn save(&self, path: &std::path::Path) -> Result<()> {
95        if let Some(parent) = path.parent() {
96            std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
97                name: "facts".into(),
98                message: format!("failed to create facts directory: {e}"),
99            })?;
100        }
101        let mut out = String::new();
102        for fact in &self.facts {
103            out.push_str(&serde_json::to_string(fact).map_err(|e| Error::Tool {
104                name: "facts".into(),
105                message: format!("failed to serialize fact: {e}"),
106            })?);
107            out.push('\n');
108        }
109        std::fs::write(path, out).map_err(|e| Error::Tool {
110            name: "facts".into(),
111            message: format!("failed to write facts file: {e}"),
112        })?;
113        Ok(())
114    }
115
116    /// Generate the next monotonic fact ID.
117    fn next_id(&self) -> String {
118        let max = self
119            .facts
120            .iter()
121            .filter_map(|f| f.id.strip_prefix('F'))
122            .filter_map(|s| s.parse::<u32>().ok())
123            .max()
124            .unwrap_or(0);
125        format!("F{}", max + 1)
126    }
127
128    /// Add a new fact, returning its ID.
129    fn add(&mut self, text: String, tags: Vec<String>, source: Option<String>) -> String {
130        let id = self.next_id();
131        let now = chrono_now_rfc3339();
132        self.facts.push(Fact {
133            id: id.clone(),
134            text,
135            tags,
136            source,
137            created_at: now.clone(),
138            last_accessed: now,
139            access_count: 0,
140            superseded_by: None,
141        });
142        id
143    }
144
145    /// Find a fact by ID.
146    fn get(&self, id: &str) -> Option<&Fact> {
147        self.facts.iter().find(|f| f.id == id)
148    }
149
150    /// Get a mutable reference to a fact by ID.
151    fn get_mut(&mut self, id: &str) -> Option<&mut Fact> {
152        self.facts.iter_mut().find(|f| f.id == id)
153    }
154
155    /// Soft-delete a fact by setting `superseded_by`.
156    fn soft_delete(&mut self, id: &str, superseded_by: &str) -> bool {
157        if let Some(fact) = self.get_mut(id) {
158            fact.superseded_by = Some(superseded_by.to_string());
159            true
160        } else {
161            false
162        }
163    }
164
165    /// Return all active (non-superseded) facts.
166    fn active_facts(&self) -> Vec<&Fact> {
167        self.facts.iter().filter(|f| f.is_active()).collect()
168    }
169
170    /// Evict the stalest facts until we're under `cap` active facts.
171    /// Staleness = days since last_accessed * (1.0 / (access_count + 1)).
172    /// Returns the number of facts evicted.
173    fn evict_to_cap(&mut self, cap: usize) -> usize {
174        let active_count = self.active_facts().len();
175        if active_count <= cap {
176            return 0;
177        }
178        let to_remove = active_count - cap;
179
180        // Score each active fact by staleness
181        let now_secs = std::time::SystemTime::now()
182            .duration_since(std::time::UNIX_EPOCH)
183            .unwrap_or_default()
184            .as_secs_f64();
185        let mut scored: Vec<(usize, f64)> = self
186            .facts
187            .iter()
188            .enumerate()
189            .filter(|(_, f)| f.is_active())
190            .map(|(i, f)| {
191                let last_access_secs = rfc3339_to_secs(&f.last_accessed).unwrap_or(0.0);
192                let days_since = (now_secs - last_access_secs) / 86400.0;
193                let staleness = days_since * (1.0 / (f.access_count.max(1) as f64));
194                (i, staleness)
195            })
196            .collect();
197        // Sort by staleness descending (most stale first)
198        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
199
200        let mut evicted = 0;
201        for (idx, _) in scored.iter().take(to_remove) {
202            // Mark as superseded by a sentinel
203            self.facts[*idx].superseded_by = Some("__evicted__".to_string());
204            evicted += 1;
205        }
206        evicted
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Full-text search (pure Rust)
212// ---------------------------------------------------------------------------
213
214/// A search result with a relevance score.
215#[derive(Debug, Clone)]
216pub struct ScoredFact {
217    pub fact: Fact,
218    pub score: f64,
219}
220
221/// Tokenize text into lowercase terms, splitting on whitespace and punctuation.
222fn tokenize(text: &str) -> Vec<String> {
223    text.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
224        .filter(|s| !s.is_empty())
225        .map(|s| s.to_lowercase())
226        .collect()
227}
228
229/// Stop words to filter out.
230const STOP_WORDS: &[&str] = &[
231    "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
232    "from", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does",
233    "did", "will", "would", "could", "should", "may", "might", "shall", "can", "it", "its", "this",
234    "that", "these", "those", "i", "you", "he", "she", "we", "they", "not", "no", "nor", "so",
235    "if", "then", "than", "too", "very", "just", "about", "up", "out", "also", "more", "some",
236    "any", "each", "every", "all", "both", "few", "most", "other", "into", "over", "such", "only",
237    "own", "same", "as", "but", "not",
238];
239
240/// Search facts by query text, returning scored results sorted by relevance.
241///
242/// Scoring formula:
243///   score = term_frequency * tag_match * recency_boost * popularity_boost
244///
245/// - term_frequency: fraction of query terms that appear in the fact text
246/// - tag_match: 1.2 if any query term matches a tag, else 1.0
247/// - recency_boost: 1.0 + 0.1 / (days_since_created + 1)
248/// - popularity_boost: 1.0 + 0.05 * ln(access_count + 1)
249pub fn search_facts(
250    facts: &[&Fact],
251    query: &str,
252    tag: Option<&str>,
253    limit: usize,
254) -> Vec<ScoredFact> {
255    let query_terms: Vec<String> = tokenize(query)
256        .into_iter()
257        .filter(|t| !STOP_WORDS.contains(&t.as_str()))
258        .collect();
259
260    let now_secs = std::time::SystemTime::now()
261        .duration_since(std::time::UNIX_EPOCH)
262        .unwrap_or_default()
263        .as_secs_f64();
264
265    let mut scored: Vec<ScoredFact> = facts
266        .iter()
267        .filter(|f| f.is_active())
268        .filter(|f| {
269            // Tag filter
270            tag.map_or(true, |t| f.tags.iter().any(|ft| ft == t))
271        })
272        .filter(|f| {
273            // If no query terms, include all (tag-only search)
274            if query_terms.is_empty() {
275                return true;
276            }
277            // At least one query term must appear in text or tags
278            let text_lower = f.text.to_lowercase();
279            let tag_text: String = f.tags.join(" ").to_lowercase();
280            query_terms
281                .iter()
282                .any(|t| text_lower.contains(t) || tag_text.contains(t))
283        })
284        .map(|f| {
285            let text_lower = f.text.to_lowercase();
286            let tag_text: String = f.tags.join(" ").to_lowercase();
287
288            // Term frequency: fraction of query terms present
289            let term_frequency = if query_terms.is_empty() {
290                1.0
291            } else {
292                let present = query_terms
293                    .iter()
294                    .filter(|t| text_lower.contains(t.as_str()) || tag_text.contains(t.as_str()))
295                    .count();
296                present as f64 / query_terms.len() as f64
297            };
298
299            // Tag match boost
300            let tag_match = if query_terms.iter().any(|t| tag_text.contains(t)) {
301                1.2
302            } else {
303                1.0
304            };
305
306            // Recency boost
307            let created_secs = rfc3339_to_secs(&f.created_at).unwrap_or(0.0);
308            let days_since = ((now_secs - created_secs) / 86400.0).max(0.0);
309            let recency_boost = 1.0 + 0.1 / (days_since + 1.0);
310
311            // Popularity boost
312            let popularity_boost = 1.0 + 0.05 * ((f.access_count + 1) as f64).ln();
313
314            let score = term_frequency * tag_match * recency_boost * popularity_boost;
315
316            ScoredFact {
317                fact: (*f).clone(),
318                score,
319            }
320        })
321        .collect();
322
323    // Sort by score descending
324    scored.sort_by(|a, b| {
325        b.score
326            .partial_cmp(&a.score)
327            .unwrap_or(std::cmp::Ordering::Equal)
328    });
329    scored.truncate(limit);
330    scored
331}
332
333// ---------------------------------------------------------------------------
334// Deduplication (Jaccard similarity)
335// ---------------------------------------------------------------------------
336
337/// Compute Jaccard similarity between two strings (token-level).
338fn jaccard_similarity(a: &str, b: &str) -> f64 {
339    let tokens_a: HashSet<String> = tokenize(a).into_iter().collect();
340    let tokens_b: HashSet<String> = tokenize(b).into_iter().collect();
341
342    if tokens_a.is_empty() && tokens_b.is_empty() {
343        return 1.0;
344    }
345
346    let intersection: HashSet<&String> = tokens_a.intersection(&tokens_b).collect();
347    let union_size = tokens_a.len() + tokens_b.len() - intersection.len();
348    if union_size == 0 {
349        return 0.0;
350    }
351    intersection.len() as f64 / union_size as f64
352}
353
354/// Check if a new fact text is a duplicate of an existing active fact.
355/// Returns the ID of the existing fact if similarity >= threshold.
356fn find_duplicate(facts: &[&Fact], text: &str, threshold: f64) -> Option<String> {
357    for fact in facts {
358        if !fact.is_active() {
359            continue;
360        }
361        let sim = jaccard_similarity(&fact.text, text);
362        if sim >= threshold {
363            // If the new text is longer/more specific, supersede the old one
364            if text.len() > fact.text.len() {
365                return Some(fact.id.clone());
366            }
367            // Otherwise the existing fact is kept; return its ID to indicate
368            // that the new one should not be added
369            return Some(fact.id.clone());
370        }
371    }
372    None
373}
374
375// ---------------------------------------------------------------------------
376// Timestamp helpers
377// ---------------------------------------------------------------------------
378
379/// Get an RFC 3339 timestamp string.
380fn chrono_now_rfc3339() -> String {
381    let dur = std::time::SystemTime::now()
382        .duration_since(std::time::UNIX_EPOCH)
383        .unwrap_or_default();
384    let secs = dur.as_secs();
385    let days = secs / 86400;
386    let time_secs = secs % 86400;
387    let hours = time_secs / 3600;
388    let minutes = (time_secs % 3600) / 60;
389    let seconds = time_secs % 60;
390
391    let (year, month, day) = days_to_date(days);
392    format!(
393        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
394        year, month, day, hours, minutes, seconds
395    )
396}
397
398/// Convert days since Unix epoch to (year, month, day).
399fn days_to_date(mut days: u64) -> (u64, u64, u64) {
400    let mut year: u64 = 1970;
401    loop {
402        let days_in_year = if is_leap(year) { 366 } else { 365 };
403        if days < days_in_year {
404            break;
405        }
406        days -= days_in_year;
407        year += 1;
408    }
409    let months_days: [u64; 12] = if is_leap(year) {
410        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
411    } else {
412        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
413    };
414    let mut month: u64 = 1;
415    for &md in &months_days {
416        if days < md {
417            break;
418        }
419        days -= md;
420        month += 1;
421    }
422    let day = days + 1;
423    (year, month, day)
424}
425
426fn is_leap(year: u64) -> bool {
427    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
428}
429
430/// Parse an RFC 3339 timestamp to seconds since Unix epoch.
431fn rfc3339_to_secs(s: &str) -> Option<f64> {
432    // Format: "2026-05-25T12:34:56Z"
433    if s.len() < 20 {
434        return None;
435    }
436    let year: u64 = s[0..4].parse().ok()?;
437    let month: u64 = s[5..7].parse().ok()?;
438    let day: u64 = s[8..10].parse().ok()?;
439    let hour: u64 = s[11..13].parse().ok()?;
440    let min: u64 = s[14..16].parse().ok()?;
441    let sec: u64 = s[17..19].parse().ok()?;
442
443    let days = days_since_epoch(year, month, day);
444    Some((days * 86400 + hour * 3600 + min * 60 + sec) as f64)
445}
446
447fn days_since_epoch(year: u64, month: u64, day: u64) -> u64 {
448    let mut total = 0u64;
449    for yr in 1970..year {
450        total += if is_leap(yr) { 366 } else { 365 };
451    }
452    let months_days: [u64; 12] = if is_leap(year) {
453        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
454    } else {
455        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
456    };
457    for md in months_days.iter().take((month - 1) as usize) {
458        total += md;
459    }
460    total + day - 1
461}
462
463// ---------------------------------------------------------------------------
464// Path helpers
465// ---------------------------------------------------------------------------
466
467/// Determine the facts file path for a given scope.
468pub fn facts_path(workspace: &std::path::Path, scope: &str) -> PathBuf {
469    match scope {
470        "global" => {
471            if let Some(home) = std::env::var_os("HOME") {
472                PathBuf::from(home)
473                    .join(".recursive")
474                    .join("memory")
475                    .join("facts.jsonl")
476            } else {
477                workspace
478                    .join(".recursive")
479                    .join("memory")
480                    .join("facts.jsonl")
481            }
482        }
483        _ => workspace
484            .join(".recursive")
485            .join("memory")
486            .join("facts.jsonl"),
487    }
488}
489
490/// Load the fact store for a given scope.
491pub fn load_facts(workspace: &std::path::Path, scope: &str) -> Result<FactStore> {
492    let path = facts_path(workspace, scope);
493    FactStore::load(&path)
494}
495
496/// Build a facts summary string for injection into the system prompt.
497/// Merges workspace-scoped and global-scoped facts, then returns the top N
498/// most recently accessed facts as a formatted block.
499pub fn facts_summary(workspace: &std::path::Path, limit: usize) -> String {
500    // Load both scopes; silently ignore missing files.
501    let workspace_store = load_facts(workspace, "workspace").unwrap_or_default();
502    let global_store = load_facts(workspace, "global").unwrap_or_default();
503
504    // Merge active facts from both scopes; global facts come first so that
505    // user-identity facts survive even when workspace has many project facts.
506    let mut all_facts: Vec<Fact> = global_store
507        .active_facts()
508        .into_iter()
509        .chain(workspace_store.active_facts())
510        .cloned()
511        .collect();
512
513    if all_facts.is_empty() {
514        return String::new();
515    }
516
517    // Sort by last_accessed descending (most recently accessed first)
518    all_facts.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
519    let sorted: Vec<&Fact> = all_facts.iter().collect();
520
521    let mut lines: Vec<String> = Vec::new();
522    lines.push(format!(
523        "# Facts (top {} most recently accessed; use `recall` for more)",
524        limit
525    ));
526    for fact in sorted.iter().take(limit) {
527        let tags_str = if fact.tags.is_empty() {
528            String::new()
529        } else {
530            format!(" [{}]", fact.tags.join(","))
531        };
532        let source_str = if let Some(ref src) = fact.source {
533            format!(" (source: {})", src)
534        } else {
535            String::new()
536        };
537        let text_preview = if fact.text.chars().count() > 120 {
538            format!("{}...", crate::truncate_str(&fact.text, 117))
539        } else {
540            fact.text.clone()
541        };
542        lines.push(format!(
543            "- {}{}{} {}",
544            fact.id, tags_str, source_str, text_preview
545        ));
546    }
547    lines.join("\n")
548}
549
550// ---------------------------------------------------------------------------
551// Tool implementations
552// ---------------------------------------------------------------------------
553
554/// Maximum active facts per scope before eviction kicks in.
555const FACTS_CAP: usize = 500;
556
557/// Jaccard similarity threshold for deduplication.
558const DEDUP_THRESHOLD: f64 = 0.7;
559
560pub struct RememberFact {
561    workspace: PathBuf,
562    lock: Mutex<()>,
563}
564
565impl RememberFact {
566    pub fn new(workspace: impl Into<PathBuf>) -> Self {
567        Self {
568            workspace: workspace.into(),
569            lock: Mutex::new(()),
570        }
571    }
572}
573
574#[async_trait]
575impl Tool for RememberFact {
576    fn spec(&self) -> ToolSpec {
577        ToolSpec {
578            name: "remember".into(),
579            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(),
580            parameters: json!({
581                "type": "object",
582                "properties": {
583                    "text": {
584                        "type": "string",
585                        "description": "The fact text to remember"
586                    },
587                    "tags": {
588                        "type": "array",
589                        "items": {"type": "string"},
590                        "description": "Optional tags for categorising the fact"
591                    },
592                    "source": {
593                        "type": "string",
594                        "description": "Optional source/provenance of the fact (e.g. 'user', 'agent')"
595                    },
596                    "scope": {
597                        "type": "string",
598                        "description": "Scope: 'workspace' (default) or 'global'",
599                        "default": "workspace"
600                    }
601                },
602                "required": ["text"]
603            }),
604        }
605    }
606
607    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
608        crate::tools::ToolSideEffect::Mutating
609    }
610
611    async fn execute(&self, arguments: Value) -> Result<String> {
612        let text = arguments["text"]
613            .as_str()
614            .ok_or_else(|| Error::BadToolArgs {
615                name: "remember".into(),
616                message: "missing required parameter: text".to_string(),
617            })?
618            .to_string();
619
620        let tags: Vec<String> = arguments["tags"]
621            .as_array()
622            .map(|arr| {
623                arr.iter()
624                    .filter_map(|v| v.as_str().map(String::from))
625                    .collect()
626            })
627            .unwrap_or_default();
628
629        let source: Option<String> = arguments["source"].as_str().map(String::from);
630
631        let scope = arguments["scope"]
632            .as_str()
633            .unwrap_or("workspace")
634            .to_string();
635
636        let _guard = self.lock.lock().unwrap();
637        let path = facts_path(&self.workspace, &scope);
638        let mut store = FactStore::load(&path)?;
639
640        // Deduplication check
641        let active: Vec<&Fact> = store.active_facts();
642        if let Some(dup_id) = find_duplicate(&active, &text, DEDUP_THRESHOLD) {
643            let dup = store.get(&dup_id).unwrap();
644            // If the new text is longer/more specific, supersede the old one
645            if text.len() > dup.text.len() {
646                store.soft_delete(&dup_id, &store.next_id());
647                let id = store.add(text, tags, source);
648                store.save(&path)?;
649                return Ok(format!("saved fact {id} (superseded {dup_id})"));
650            } else {
651                // Existing fact is kept; update its access stats
652                if let Some(existing) = store.get_mut(&dup_id) {
653                    existing.last_accessed = chrono_now_rfc3339();
654                    existing.access_count += 1;
655                }
656                store.save(&path)?;
657                return Ok(format!("duplicate of {dup_id}, kept existing"));
658            }
659        }
660
661        // Eviction check
662        store.evict_to_cap(FACTS_CAP);
663        // Evict to cap-1 so that after adding the new fact we stay at cap
664        store.evict_to_cap(FACTS_CAP.saturating_sub(1));
665
666        let id = store.add(text, tags, source);
667        store.save(&path)?;
668        Ok(format!("saved fact {id}"))
669    }
670}
671
672pub struct RecallFact {
673    workspace: PathBuf,
674}
675
676impl RecallFact {
677    pub fn new(workspace: impl Into<PathBuf>) -> Self {
678        Self {
679            workspace: workspace.into(),
680        }
681    }
682}
683
684#[async_trait]
685impl Tool for RecallFact {
686    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
687        crate::tools::ToolSideEffect::ReadOnly
688    }
689
690    fn spec(&self) -> ToolSpec {
691        ToolSpec {
692            name: "recall".into(),
693            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(),
694            parameters: json!({
695                "type": "object",
696                "properties": {
697                    "query": {
698                        "type": "string",
699                        "description": "Search query for full-text search across fact text and tags"
700                    },
701                    "tag": {
702                        "type": "string",
703                        "description": "Exact tag to filter by"
704                    },
705                    "limit": {
706                        "type": "integer",
707                        "description": "Maximum number of results (default 10)",
708                        "default": 10
709                    },
710                    "scope": {
711                        "type": "string",
712                        "description": "Scope: 'workspace' (default) or 'global'",
713                        "default": "workspace"
714                    }
715                }
716            }),
717        }
718    }
719
720    async fn execute(&self, arguments: Value) -> Result<String> {
721        let query = arguments["query"].as_str().unwrap_or("");
722        let tag = arguments["tag"].as_str();
723        let limit = arguments["limit"].as_i64().unwrap_or(10) as usize;
724        let scope = arguments["scope"].as_str().unwrap_or("workspace");
725
726        let path = facts_path(&self.workspace, scope);
727        let mut store = FactStore::load(&path)?;
728        let active: Vec<&Fact> = store.active_facts();
729
730        let results = if query.is_empty() && tag.is_none() {
731            // No query: return most recently accessed
732            let mut sorted: Vec<&Fact> = active;
733            sorted.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
734            sorted.truncate(limit);
735            sorted
736                .into_iter()
737                .map(|f| ScoredFact {
738                    fact: f.clone(),
739                    score: 0.0,
740                })
741                .collect()
742        } else {
743            search_facts(&active, query, tag, limit)
744        };
745
746        if results.is_empty() {
747            return Ok("no matching facts found".to_string());
748        }
749
750        // Update access stats for returned facts
751        for scored in &results {
752            if let Some(fact) = store.get_mut(&scored.fact.id) {
753                fact.last_accessed = chrono_now_rfc3339();
754                fact.access_count += 1;
755            }
756        }
757        store.save(&path)?;
758
759        let lines: Vec<String> = results
760            .iter()
761            .map(|sf| {
762                let tags_str = if sf.fact.tags.is_empty() {
763                    String::new()
764                } else {
765                    format!(" [{}]", sf.fact.tags.join(","))
766                };
767                let source_str = if let Some(ref src) = sf.fact.source {
768                    format!(" (source: {})", src)
769                } else {
770                    String::new()
771                };
772                format!("{}{}{} {}", sf.fact.id, tags_str, source_str, sf.fact.text)
773            })
774            .collect();
775
776        Ok(lines.join("\n"))
777    }
778}
779
780pub struct ForgetFact {
781    workspace: PathBuf,
782    lock: Mutex<()>,
783}
784
785impl ForgetFact {
786    pub fn new(workspace: impl Into<PathBuf>) -> Self {
787        Self {
788            workspace: workspace.into(),
789            lock: Mutex::new(()),
790        }
791    }
792}
793
794#[async_trait]
795impl Tool for ForgetFact {
796    fn spec(&self) -> ToolSpec {
797        ToolSpec {
798            name: "forget".into(),
799            description: "Remove a fact from persistent memory by its ID (soft delete).".into(),
800            parameters: json!({
801                "type": "object",
802                "properties": {
803                    "id": {
804                        "type": "string",
805                        "description": "The ID of the fact to remove (e.g. F3)"
806                    },
807                    "scope": {
808                        "type": "string",
809                        "description": "Scope: 'workspace' (default) or 'global'",
810                        "default": "workspace"
811                    }
812                },
813                "required": ["id"]
814            }),
815        }
816    }
817
818    async fn execute(&self, arguments: Value) -> Result<String> {
819        let id = arguments["id"]
820            .as_str()
821            .ok_or_else(|| Error::BadToolArgs {
822                name: "forget".into(),
823                message: "missing required parameter: id".to_string(),
824            })?
825            .to_string();
826
827        let scope = arguments["scope"].as_str().unwrap_or("workspace");
828
829        let _guard = self.lock.lock().unwrap();
830        let path = facts_path(&self.workspace, scope);
831        let mut store = FactStore::load(&path)?;
832
833        if store.get(&id).is_none() {
834            return Ok(format!("no such fact: {id}"));
835        }
836
837        store.soft_delete(&id, "__forgotten__");
838        store.save(&path)?;
839        Ok(format!("forgotten fact {id}"))
840    }
841}
842
843pub struct UpdateFact {
844    workspace: PathBuf,
845    lock: Mutex<()>,
846}
847
848impl UpdateFact {
849    pub fn new(workspace: impl Into<PathBuf>) -> Self {
850        Self {
851            workspace: workspace.into(),
852            lock: Mutex::new(()),
853        }
854    }
855}
856
857#[async_trait]
858impl Tool for UpdateFact {
859    fn spec(&self) -> ToolSpec {
860        ToolSpec {
861            name: "update_fact".into(),
862            description: "Update an existing fact with new text. Creates a new version and links the old one as superseded.".into(),
863            parameters: json!({
864                "type": "object",
865                "properties": {
866                    "id": {
867                        "type": "string",
868                        "description": "The ID of the fact to update (e.g. F3)"
869                    },
870                    "new_text": {
871                        "type": "string",
872                        "description": "The new text for the fact"
873                    },
874                    "scope": {
875                        "type": "string",
876                        "description": "Scope: 'workspace' (default) or 'global'",
877                        "default": "workspace"
878                    }
879                },
880                "required": ["id", "new_text"]
881            }),
882        }
883    }
884
885    async fn execute(&self, arguments: Value) -> Result<String> {
886        let id = arguments["id"]
887            .as_str()
888            .ok_or_else(|| Error::BadToolArgs {
889                name: "update_fact".into(),
890                message: "missing required parameter: id".to_string(),
891            })?
892            .to_string();
893
894        let new_text = arguments["new_text"]
895            .as_str()
896            .ok_or_else(|| Error::BadToolArgs {
897                name: "update_fact".into(),
898                message: "missing required parameter: new_text".to_string(),
899            })?
900            .to_string();
901
902        let scope = arguments["scope"].as_str().unwrap_or("workspace");
903
904        let _guard = self.lock.lock().unwrap();
905        let path = facts_path(&self.workspace, scope);
906        let mut store = FactStore::load(&path)?;
907
908        let existing = store.get(&id).ok_or_else(|| Error::BadToolArgs {
909            name: "update_fact".into(),
910            message: format!("no such fact: {id}"),
911        })?;
912
913        let new_id = store.next_id();
914        let tags = existing.tags.clone();
915        let source = existing.source.clone();
916
917        // Soft-delete the old fact
918        store.soft_delete(&id, &new_id);
919
920        // Add the new version
921        let now = chrono_now_rfc3339();
922        store.facts.push(Fact {
923            id: new_id.clone(),
924            text: new_text,
925            tags,
926            source,
927            created_at: now.clone(),
928            last_accessed: now,
929            access_count: 0,
930            superseded_by: None,
931        });
932
933        store.save(&path)?;
934        Ok(format!("updated fact {id} -> {new_id}"))
935    }
936}
937
938// ---------------------------------------------------------------------------
939// Tests
940// ---------------------------------------------------------------------------
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    /// Helper: create a temporary workspace dir.
947    fn tmp_workspace() -> (tempfile::TempDir, PathBuf) {
948        let tmp = tempfile::TempDir::new().unwrap();
949        let ws = tmp.path().to_path_buf();
950        (tmp, ws)
951    }
952
953    #[test]
954    fn test_a_remember_recall_roundtrip() {
955        let (_tmp, ws) = tmp_workspace();
956        let remember = RememberFact::new(&ws);
957        let recall = RecallFact::new(&ws);
958
959        // Remember a fact
960        let result = tokio::runtime::Runtime::new()
961            .unwrap()
962            .block_on(remember.execute(json!({
963                "text": "Rust uses the Result type for error handling",
964                "tags": ["rust", "error-handling"],
965                "source": "agent"
966            })));
967        assert!(result.is_ok());
968        let msg = result.unwrap();
969        assert!(msg.starts_with("saved fact F"), "got: {msg}");
970
971        // Recall by query
972        let result = tokio::runtime::Runtime::new()
973            .unwrap()
974            .block_on(recall.execute(json!({"query": "Result type"})));
975        assert!(result.is_ok());
976        let output = result.unwrap();
977        assert!(output.contains("F1"), "output: {output}");
978        assert!(output.contains("Result type"), "output: {output}");
979        assert!(output.contains("[rust,error-handling]"), "output: {output}");
980    }
981
982    #[test]
983    fn test_b_duplicate_detection() {
984        let (_tmp, ws) = tmp_workspace();
985        let remember = RememberFact::new(&ws);
986
987        // First fact
988        tokio::runtime::Runtime::new()
989            .unwrap()
990            .block_on(remember.execute(json!({
991                "text": "Rust uses the Result type for error handling",
992                "tags": ["rust"]
993            })))
994            .unwrap();
995
996        // Similar fact — should be detected as duplicate
997        let result = tokio::runtime::Runtime::new()
998            .unwrap()
999            .block_on(remember.execute(json!({
1000                "text": "Rust uses Result for error handling",
1001                "tags": ["rust"]
1002            })));
1003        assert!(result.is_ok());
1004        let msg = result.unwrap();
1005        assert!(msg.contains("duplicate of F1"), "got: {msg}");
1006    }
1007
1008    #[test]
1009    fn test_c_tag_filtering() {
1010        let (_tmp, ws) = tmp_workspace();
1011        let remember = RememberFact::new(&ws);
1012        let recall = RecallFact::new(&ws);
1013
1014        tokio::runtime::Runtime::new()
1015            .unwrap()
1016            .block_on(remember.execute(json!({
1017                "text": "Python uses exceptions for error handling",
1018                "tags": ["python"]
1019            })))
1020            .unwrap();
1021
1022        tokio::runtime::Runtime::new()
1023            .unwrap()
1024            .block_on(remember.execute(json!({
1025                "text": "Rust uses Result for error handling",
1026                "tags": ["rust"]
1027            })))
1028            .unwrap();
1029
1030        // Filter by tag
1031        let result = tokio::runtime::Runtime::new()
1032            .unwrap()
1033            .block_on(recall.execute(json!({"tag": "rust"})));
1034        assert!(result.is_ok());
1035        let output = result.unwrap();
1036        assert!(output.contains("F2"), "output: {output}");
1037        assert!(!output.contains("F1"), "output: {output}");
1038    }
1039
1040    #[test]
1041    fn test_d_access_count_increments() {
1042        let (_tmp, ws) = tmp_workspace();
1043        let remember = RememberFact::new(&ws);
1044        let recall = RecallFact::new(&ws);
1045
1046        tokio::runtime::Runtime::new()
1047            .unwrap()
1048            .block_on(remember.execute(json!({
1049                "text": "Important fact",
1050                "tags": ["test"]
1051            })))
1052            .unwrap();
1053
1054        // Recall multiple times
1055        for _ in 0..3 {
1056            tokio::runtime::Runtime::new()
1057                .unwrap()
1058                .block_on(recall.execute(json!({"query": "Important"})))
1059                .unwrap();
1060        }
1061
1062        // Load store and check access count
1063        let path = facts_path(&ws, "workspace");
1064        let store = FactStore::load(&path).unwrap();
1065        let fact = store.get("F1").unwrap();
1066        assert_eq!(fact.access_count, 3, "access count should be 3");
1067    }
1068
1069    #[test]
1070    fn test_e_eviction_at_cap() {
1071        let (_tmp, ws) = tmp_workspace();
1072        let remember = RememberFact::new(&ws);
1073
1074        // Add FACTS_CAP + 10 facts
1075        for i in 0..FACTS_CAP + 10 {
1076            tokio::runtime::Runtime::new()
1077                .unwrap()
1078                .block_on(remember.execute(json!({
1079                    "text": format!("Fact number {}", i),
1080                    "tags": ["test"]
1081                })))
1082                .unwrap();
1083        }
1084
1085        // Load store and check active count
1086        let path = facts_path(&ws, "workspace");
1087        let store = FactStore::load(&path).unwrap();
1088        let active = store.active_facts();
1089        assert!(
1090            active.len() <= FACTS_CAP,
1091            "active facts: {} > cap {}",
1092            active.len(),
1093            FACTS_CAP
1094        );
1095    }
1096
1097    #[test]
1098    fn test_f_forget_marks_superseded() {
1099        let (_tmp, ws) = tmp_workspace();
1100        let remember = RememberFact::new(&ws);
1101        let forget = ForgetFact::new(&ws);
1102
1103        tokio::runtime::Runtime::new()
1104            .unwrap()
1105            .block_on(remember.execute(json!({
1106                "text": "Something to forget",
1107                "tags": ["test"]
1108            })))
1109            .unwrap();
1110
1111        let result = tokio::runtime::Runtime::new()
1112            .unwrap()
1113            .block_on(forget.execute(json!({"id": "F1"})));
1114        assert!(result.is_ok());
1115        assert_eq!(result.unwrap(), "forgotten fact F1");
1116
1117        // Verify it's no longer active
1118        let path = facts_path(&ws, "workspace");
1119        let store = FactStore::load(&path).unwrap();
1120        let fact = store.get("F1").unwrap();
1121        assert_eq!(fact.superseded_by, Some("__forgotten__".to_string()));
1122        assert!(!fact.is_active());
1123    }
1124
1125    #[test]
1126    fn test_g_update_fact_creates_new_version() {
1127        let (_tmp, ws) = tmp_workspace();
1128        let remember = RememberFact::new(&ws);
1129        let update = UpdateFact::new(&ws);
1130
1131        tokio::runtime::Runtime::new()
1132            .unwrap()
1133            .block_on(remember.execute(json!({
1134                "text": "Old version of the fact",
1135                "tags": ["test"]
1136            })))
1137            .unwrap();
1138
1139        let result = tokio::runtime::Runtime::new()
1140            .unwrap()
1141            .block_on(update.execute(json!({
1142                "id": "F1",
1143                "new_text": "New improved version of the fact"
1144            })));
1145        assert!(result.is_ok());
1146        let msg = result.unwrap();
1147        assert!(msg.contains("F1 -> F2"), "got: {msg}");
1148
1149        // Verify old fact is superseded
1150        let path = facts_path(&ws, "workspace");
1151        let store = FactStore::load(&path).unwrap();
1152        let old = store.get("F1").unwrap();
1153        assert_eq!(old.superseded_by, Some("F2".to_string()));
1154        let new_fact = store.get("F2").unwrap();
1155        assert_eq!(new_fact.text, "New improved version of the fact");
1156        assert!(new_fact.is_active());
1157    }
1158
1159    #[test]
1160    fn test_h_search_scoring() {
1161        let (_tmp, ws) = tmp_workspace();
1162        let remember = RememberFact::new(&ws);
1163
1164        // Add facts with varying relevance
1165        tokio::runtime::Runtime::new()
1166            .unwrap()
1167            .block_on(remember.execute(json!({
1168                "text": "Rust is a systems programming language",
1169                "tags": ["rust"]
1170            })))
1171            .unwrap();
1172
1173        tokio::runtime::Runtime::new()
1174            .unwrap()
1175            .block_on(remember.execute(json!({
1176                "text": "Python is great for data science",
1177                "tags": ["python"]
1178            })))
1179            .unwrap();
1180
1181        tokio::runtime::Runtime::new()
1182            .unwrap()
1183            .block_on(remember.execute(json!({
1184                "text": "Rust has excellent performance characteristics",
1185                "tags": ["rust", "performance"]
1186            })))
1187            .unwrap();
1188
1189        // Search for "rust"
1190        let path = facts_path(&ws, "workspace");
1191        let store = FactStore::load(&path).unwrap();
1192        let active: Vec<&Fact> = store.active_facts();
1193        let results = search_facts(&active, "rust", None, 10);
1194
1195        assert_eq!(results.len(), 2, "should find 2 rust facts");
1196        // Both should have "rust" in them
1197        for r in &results {
1198            assert!(
1199                r.fact.text.to_lowercase().contains("rust"),
1200                "fact: {}",
1201                r.fact.text
1202            );
1203        }
1204    }
1205
1206    #[test]
1207    fn test_i_scope_isolation() {
1208        let (_tmp, ws) = tmp_workspace();
1209
1210        // Override HOME to the temp dir so global-scope facts land in a
1211        // writable location (macOS security policy may block writes to
1212        // ~/.recursive/memory/ in test environments). The guard holds
1213        // the cross-module env lock and restores HOME on drop, so this
1214        // test no longer pollutes parallel tests that read $HOME.
1215        let home = ws.join("home");
1216        std::fs::create_dir_all(&home).unwrap();
1217        let _g = crate::test_util::PinnedHome::new(&home);
1218
1219        let remember = RememberFact::new(&ws);
1220        let recall = RecallFact::new(&ws);
1221
1222        // Add workspace fact
1223        tokio::runtime::Runtime::new()
1224            .unwrap()
1225            .block_on(remember.execute(json!({
1226                "text": "Workspace-specific fact",
1227                "scope": "workspace"
1228            })))
1229            .unwrap();
1230
1231        // Add global fact
1232        tokio::runtime::Runtime::new()
1233            .unwrap()
1234            .block_on(remember.execute(json!({
1235                "text": "Global fact",
1236                "scope": "global"
1237            })))
1238            .unwrap();
1239
1240        // Recall workspace only
1241        let result = tokio::runtime::Runtime::new()
1242            .unwrap()
1243            .block_on(recall.execute(json!({"scope": "workspace", "query": "fact"})));
1244        assert!(result.is_ok());
1245        let output = result.unwrap();
1246        assert!(output.contains("Workspace-specific"), "output: {output}");
1247        assert!(!output.contains("Global"), "output: {output}");
1248
1249        // Recall global only
1250        let result = tokio::runtime::Runtime::new()
1251            .unwrap()
1252            .block_on(recall.execute(json!({"scope": "global", "query": "fact"})));
1253        assert!(result.is_ok());
1254        let output = result.unwrap();
1255        assert!(output.contains("Global"), "output: {output}");
1256        assert!(!output.contains("Workspace-specific"), "output: {output}");
1257    }
1258
1259    #[test]
1260    fn test_j_tokenize_and_stop_words() {
1261        let tokens = tokenize("Hello, World! This is a test.");
1262        assert!(tokens.contains(&"hello".to_string()));
1263        assert!(tokens.contains(&"world".to_string()));
1264        assert!(tokens.contains(&"test".to_string()));
1265        // Stop words should still be in tokenize output (filtering is done in search)
1266        assert!(tokens.contains(&"this".to_string()));
1267    }
1268
1269    #[test]
1270    fn test_k_jaccard_similarity() {
1271        let sim = jaccard_similarity(
1272            "Rust uses Result for error handling",
1273            "Rust uses Result for error handling",
1274        );
1275        assert!(
1276            (sim - 1.0).abs() < 0.01,
1277            "identical texts should have sim=1, got {sim}"
1278        );
1279
1280        let sim = jaccard_similarity("Rust uses Result", "Python uses exceptions");
1281        assert!(sim < 0.5, "different texts should have low sim, got {sim}");
1282
1283        let sim = jaccard_similarity("", "");
1284        assert!(
1285            (sim - 1.0).abs() < 0.01,
1286            "empty strings should have sim=1, got {sim}"
1287        );
1288    }
1289
1290    #[test]
1291    fn test_l_rfc3339_to_secs() {
1292        let secs = rfc3339_to_secs("2026-05-25T12:00:00Z").unwrap();
1293        assert!(secs > 0.0, "should parse to positive seconds");
1294    }
1295
1296    #[test]
1297    fn test_m_facts_summary_empty() {
1298        let (_tmp, ws) = tmp_workspace();
1299        // Pin HOME to an empty temp dir so global facts on the developer's
1300        // real machine don't leak into this test (facts_summary now merges
1301        // both workspace and global scopes).
1302        let fake_home = _tmp.path().join("home");
1303        std::fs::create_dir_all(&fake_home).unwrap();
1304        let _pin = crate::test_util::PinnedHome::new(&fake_home);
1305        let summary = facts_summary(&ws, 5);
1306        assert_eq!(summary, "");
1307    }
1308
1309    #[test]
1310    fn test_n_facts_summary_with_facts() {
1311        let (_tmp, ws) = tmp_workspace();
1312        let remember = RememberFact::new(&ws);
1313
1314        tokio::runtime::Runtime::new()
1315            .unwrap()
1316            .block_on(remember.execute(json!({
1317                "text": "First fact",
1318                "tags": ["a"]
1319            })))
1320            .unwrap();
1321
1322        tokio::runtime::Runtime::new()
1323            .unwrap()
1324            .block_on(remember.execute(json!({
1325                "text": "Second fact",
1326                "tags": ["b"]
1327            })))
1328            .unwrap();
1329
1330        let summary = facts_summary(&ws, 5);
1331        assert!(!summary.is_empty());
1332        assert!(summary.contains("F1"));
1333        assert!(summary.contains("F2"));
1334        assert!(summary.contains("First fact"));
1335        assert!(summary.contains("Second fact"));
1336    }
1337}