1use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use serde_json::{json, Value};
23use std::collections::HashSet;
24use std::path::PathBuf;
25use std::sync::Mutex;
26
27use crate::error::{Error, Result};
28use crate::llm::ToolSpec;
29use crate::tools::Tool;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Fact {
38 pub id: String,
39 pub text: String,
40 #[serde(default)]
41 pub tags: Vec<String>,
42 #[serde(default)]
43 pub source: Option<String>,
44 pub created_at: String,
45 pub last_accessed: String,
46 pub access_count: u64,
47 #[serde(default)]
48 pub superseded_by: Option<String>,
49}
50
51impl Fact {
52 fn is_active(&self) -> bool {
54 self.superseded_by.is_none()
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct FactStore {
61 pub facts: Vec<Fact>,
62}
63
64impl FactStore {
65 fn load(path: &std::path::Path) -> Result<Self> {
67 if !path.exists() {
68 return Ok(Self::default());
69 }
70 let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
71 name: "facts".into(),
72 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 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 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 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 fn get(&self, id: &str) -> Option<&Fact> {
147 self.facts.iter().find(|f| f.id == id)
148 }
149
150 fn get_mut(&mut self, id: &str) -> Option<&mut Fact> {
152 self.facts.iter_mut().find(|f| f.id == id)
153 }
154
155 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 fn active_facts(&self) -> Vec<&Fact> {
167 self.facts.iter().filter(|f| f.is_active()).collect()
168 }
169
170 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 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 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 self.facts[*idx].superseded_by = Some("__evicted__".to_string());
204 evicted += 1;
205 }
206 evicted
207 }
208}
209
210#[derive(Debug, Clone)]
216pub struct ScoredFact {
217 pub fact: Fact,
218 pub score: f64,
219}
220
221fn 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
229const 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
240pub 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.map_or(true, |t| f.tags.iter().any(|ft| ft == t))
271 })
272 .filter(|f| {
273 if query_terms.is_empty() {
275 return true;
276 }
277 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 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 let tag_match = if query_terms.iter().any(|t| tag_text.contains(t)) {
301 1.2
302 } else {
303 1.0
304 };
305
306 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 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 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
333fn 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
354fn 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 text.len() > fact.text.len() {
365 return Some(fact.id.clone());
366 }
367 return Some(fact.id.clone());
370 }
371 }
372 None
373}
374
375fn 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
398fn 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
430fn rfc3339_to_secs(s: &str) -> Option<f64> {
432 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
463pub 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
490pub fn load_facts(workspace: &std::path::Path, scope: &str) -> Result<FactStore> {
492 let path = facts_path(workspace, scope);
493 FactStore::load(&path)
494}
495
496pub fn facts_summary(workspace: &std::path::Path, limit: usize) -> String {
500 let workspace_store = load_facts(workspace, "workspace").unwrap_or_default();
502 let global_store = load_facts(workspace, "global").unwrap_or_default();
503
504 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 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
550const FACTS_CAP: usize = 500;
556
557const 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 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 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 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 store.evict_to_cap(FACTS_CAP);
663 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 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 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 store.soft_delete(&id, &new_id);
919
920 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#[cfg(test)]
943mod tests {
944 use super::*;
945
946 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 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 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 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 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 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 for _ in 0..3 {
1056 tokio::runtime::Runtime::new()
1057 .unwrap()
1058 .block_on(recall.execute(json!({"query": "Important"})))
1059 .unwrap();
1060 }
1061
1062 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 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 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 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 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 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 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 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 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 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 tokio::runtime::Runtime::new()
1233 .unwrap()
1234 .block_on(remember.execute(json!({
1235 "text": "Global fact",
1236 "scope": "global"
1237 })))
1238 .unwrap();
1239
1240 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 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 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 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}