Skip to main content

recursive/tools/
memory.rs

1//! Persistent memory tools: `remember`, `recall`, `forget`.
2//!
3//! Notes are stored in `<workspace>/.recursive/memory.json` (or
4//! `~/.recursive/memory.json` if `RECURSIVE_MEMORY_GLOBAL=1`).
5//! Schema:
6//! ```json
7//! { "notes": [ { "id": "N1", "tags": ["rust"], "text": "...", "ts": "..." } ] }
8//! ```
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use std::path::PathBuf;
14use std::sync::Mutex;
15
16use crate::error::{Error, Result};
17use crate::llm::ToolSpec;
18use crate::tools::Tool;
19
20/// A single memory note.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Note {
23    pub id: String,
24    #[serde(default)]
25    pub tags: Vec<String>,
26    pub text: String,
27    pub ts: String,
28}
29
30/// The on-disk store.
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct MemoryStore {
33    pub notes: Vec<Note>,
34}
35
36impl MemoryStore {
37    /// Load from a path, returning an empty store if the file doesn't exist.
38    fn load(path: &std::path::Path) -> Result<Self> {
39        if !path.exists() {
40            return Ok(Self::default());
41        }
42        let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
43            name: "memory".into(),
44            message: format!("failed to read memory file: {e}"),
45        })?;
46        serde_json::from_str(&raw).map_err(|e| Error::Tool {
47            name: "memory".into(),
48            message: format!("malformed memory file: {e}"),
49        })
50    }
51
52    /// Save to disk, creating parent directories if needed.
53    fn save(&self, path: &std::path::Path) -> Result<()> {
54        if let Some(parent) = path.parent() {
55            std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
56                name: "memory".into(),
57                message: format!("failed to create memory directory: {e}"),
58            })?;
59        }
60        let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
61            name: "memory".into(),
62            message: format!("failed to serialize memory: {e}"),
63        })?;
64        std::fs::write(path, raw).map_err(|e| Error::Tool {
65            name: "memory".into(),
66            message: format!("failed to write memory file: {e}"),
67        })?;
68        Ok(())
69    }
70
71    /// Generate the next monotonic ID.
72    fn next_id(&self) -> String {
73        let max = self
74            .notes
75            .iter()
76            .filter_map(|n| n.id.strip_prefix('N'))
77            .filter_map(|s| s.parse::<u32>().ok())
78            .max()
79            .unwrap_or(0);
80        format!("N{}", max + 1)
81    }
82
83    /// Add a note, returning its ID.
84    fn add(&mut self, text: String, tags: Vec<String>) -> String {
85        let id = self.next_id();
86        let ts = chrono_now_rfc3339();
87        self.notes.push(Note {
88            id: id.clone(),
89            tags,
90            text,
91            ts,
92        });
93        id
94    }
95
96    /// Remove a note by ID. Returns true if found.
97    fn remove(&mut self, id: &str) -> bool {
98        let before = self.notes.len();
99        self.notes.retain(|n| n.id != id);
100        self.notes.len() < before
101    }
102
103    /// Search notes by query (case-insensitive substring in text or tags)
104    /// or by exact tag match. Returns up to `limit` results, most recent first.
105    fn search(&self, query: Option<&str>, tag: Option<&str>, limit: usize) -> Vec<&Note> {
106        let mut results: Vec<&Note> = self
107            .notes
108            .iter()
109            .filter(|n| {
110                let matches_query = query.map_or(true, |q| {
111                    let q_lower = q.to_lowercase();
112                    n.text.to_lowercase().contains(&q_lower)
113                        || n.tags.iter().any(|t| t.to_lowercase().contains(&q_lower))
114                });
115                let matches_tag = tag.map_or(true, |t| n.tags.iter().any(|nt| nt == t));
116                matches_query && matches_tag
117            })
118            .collect();
119        // Most recent first (reverse chronological)
120        results.reverse();
121        results.truncate(limit);
122        results
123    }
124}
125
126/// Get an RFC 3339 timestamp string.
127fn chrono_now_rfc3339() -> String {
128    // Use std::time to build a simple UTC timestamp without chrono dependency.
129    let dur = std::time::SystemTime::now()
130        .duration_since(std::time::UNIX_EPOCH)
131        .unwrap_or_default();
132    let secs = dur.as_secs();
133    // Format as ISO 8601 / RFC 3339
134    let days = secs / 86400;
135    let time_secs = secs % 86400;
136    let hours = time_secs / 3600;
137    let minutes = (time_secs % 3600) / 60;
138    let seconds = time_secs % 60;
139
140    // Compute year/month/day from days since epoch (simplified)
141    let (year, month, day) = days_to_date(days);
142    format!(
143        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
144        year, month, day, hours, minutes, seconds
145    )
146}
147
148/// Convert days since Unix epoch to (year, month, day).
149/// Uses a simple leap-year-aware algorithm.
150fn days_to_date(mut days: u64) -> (u64, u64, u64) {
151    // Start from 1970-01-01
152    let mut year: u64 = 1970;
153    loop {
154        let days_in_year = if is_leap(year) { 366 } else { 365 };
155        if days < days_in_year {
156            break;
157        }
158        days -= days_in_year;
159        year += 1;
160    }
161    let months_days: [u64; 12] = if is_leap(year) {
162        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
163    } else {
164        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
165    };
166    let mut month: u64 = 1;
167    for &md in &months_days {
168        if days < md {
169            break;
170        }
171        days -= md;
172        month += 1;
173    }
174    let day = days + 1; // 1-indexed
175    (year, month, day)
176}
177
178fn is_leap(year: u64) -> bool {
179    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
180}
181
182/// Determine the memory file path based on workspace and env var.
183pub fn memory_path(workspace: &std::path::Path) -> PathBuf {
184    if std::env::var("RECURSIVE_MEMORY_GLOBAL").as_deref() == Ok("1") {
185        if let Some(home) = std::env::var_os("HOME") {
186            return PathBuf::from(home).join(".recursive").join("memory.json");
187        }
188    }
189    workspace.join(".recursive").join("memory.json")
190}
191
192/// Load the memory store from the workspace-relative path.
193pub fn load_memory(workspace: &std::path::Path) -> Result<MemoryStore> {
194    let path = memory_path(workspace);
195    MemoryStore::load(&path)
196}
197
198/// Build a memory summary string for injection into the system prompt.
199/// Returns the top N most recent notes as a formatted block, or empty
200/// string if no notes exist.
201pub fn memory_summary(workspace: &std::path::Path, limit: usize) -> String {
202    let store = match load_memory(workspace) {
203        Ok(s) => s,
204        Err(_) => return String::new(),
205    };
206    if store.notes.is_empty() {
207        return String::new();
208    }
209    let mut lines: Vec<String> = Vec::new();
210    lines.push("# Memory (top {} most recent notes; use `recall` for more)".to_string());
211    // Most recent first
212    let mut notes: Vec<&Note> = store.notes.iter().collect();
213    notes.reverse();
214    for note in notes.iter().take(limit) {
215        let tags_str = if note.tags.is_empty() {
216            String::new()
217        } else {
218            format!(" [{}]", note.tags.join(","))
219        };
220        // Truncate long text for the summary
221        let text_preview = if note.text.len() > 120 {
222            format!("{}...", &note.text[..117])
223        } else {
224            note.text.clone()
225        };
226        lines.push(format!("- {}{} {}", note.id, tags_str, text_preview));
227    }
228    lines.join("\n")
229}
230
231// ---------------------------------------------------------------------------
232// Tool implementations
233// ---------------------------------------------------------------------------
234
235pub struct Remember {
236    workspace: PathBuf,
237    /// Mutex for thread-safe access to the memory file.
238    lock: Mutex<()>,
239}
240
241impl Remember {
242    pub fn new(workspace: impl Into<PathBuf>) -> Self {
243        Self {
244            workspace: workspace.into(),
245            lock: Mutex::new(()),
246        }
247    }
248}
249
250#[async_trait]
251impl Tool for Remember {
252    fn spec(&self) -> ToolSpec {
253        ToolSpec {
254            name: "remember".into(),
255            description: "Save a note to persistent memory. The note will be available in future sessions via `recall` or injected into the system prompt.".into(),
256            parameters: json!({
257                "type": "object",
258                "properties": {
259                    "text": {
260                        "type": "string",
261                        "description": "The note text to remember"
262                    },
263                    "tags": {
264                        "type": "array",
265                        "items": {"type": "string"},
266                        "description": "Optional tags for categorising the note"
267                    }
268                },
269                "required": ["text"]
270            }),
271        }
272    }
273
274    fn is_readonly(&self) -> bool {
275        true
276    }
277
278    async fn execute(&self, arguments: Value) -> Result<String> {
279        let text = arguments["text"]
280            .as_str()
281            .ok_or_else(|| Error::BadToolArgs {
282                name: "remember".into(),
283                message: "missing required parameter: text".to_string(),
284            })?
285            .to_string();
286
287        let tags: Vec<String> = arguments["tags"]
288            .as_array()
289            .map(|arr| {
290                arr.iter()
291                    .filter_map(|v| v.as_str().map(String::from))
292                    .collect()
293            })
294            .unwrap_or_default();
295
296        let _guard = self.lock.lock().unwrap();
297        let path = memory_path(&self.workspace);
298        let mut store = MemoryStore::load(&path)?;
299        let id = store.add(text, tags);
300        store.save(&path)?;
301        Ok(format!("saved note {id}"))
302    }
303}
304
305pub struct Recall {
306    workspace: PathBuf,
307}
308
309impl Recall {
310    pub fn new(workspace: impl Into<PathBuf>) -> Self {
311        Self {
312            workspace: workspace.into(),
313        }
314    }
315}
316
317#[async_trait]
318impl Tool for Recall {
319    fn spec(&self) -> ToolSpec {
320        ToolSpec {
321            name: "recall".into(),
322            description: "Search persistent memory for notes matching a query or tag. Returns up to `limit` results, most recent first.".into(),
323            parameters: json!({
324                "type": "object",
325                "properties": {
326                    "query": {
327                        "type": "string",
328                        "description": "Case-insensitive substring to search for in note text or tags"
329                    },
330                    "tag": {
331                        "type": "string",
332                        "description": "Exact tag to filter by"
333                    },
334                    "limit": {
335                        "type": "integer",
336                        "description": "Maximum number of results (default 10)",
337                        "default": 10
338                    }
339                }
340            }),
341        }
342    }
343
344    async fn execute(&self, arguments: Value) -> Result<String> {
345        let query = arguments["query"].as_str();
346        let tag = arguments["tag"].as_str();
347        let limit = arguments["limit"].as_i64().unwrap_or(10) as usize;
348
349        let path = memory_path(&self.workspace);
350        let store = MemoryStore::load(&path)?;
351        let results = store.search(query, tag, limit);
352
353        if results.is_empty() {
354            return Ok("no matching notes found".to_string());
355        }
356
357        let lines: Vec<String> = results
358            .iter()
359            .map(|n| {
360                let tags_str = if n.tags.is_empty() {
361                    String::new()
362                } else {
363                    format!(" [{}]", n.tags.join(","))
364                };
365                format!("{}{} {}", n.id, tags_str, n.text)
366            })
367            .collect();
368
369        Ok(lines.join("\n"))
370    }
371}
372
373pub struct Forget {
374    workspace: PathBuf,
375    lock: Mutex<()>,
376}
377
378impl Forget {
379    pub fn new(workspace: impl Into<PathBuf>) -> Self {
380        Self {
381            workspace: workspace.into(),
382            lock: Mutex::new(()),
383        }
384    }
385}
386
387#[async_trait]
388impl Tool for Forget {
389    fn spec(&self) -> ToolSpec {
390        ToolSpec {
391            name: "forget".into(),
392            description: "Remove a note from persistent memory by its ID.".into(),
393            parameters: json!({
394                "type": "object",
395                "properties": {
396                    "id": {
397                        "type": "string",
398                        "description": "The ID of the note to remove (e.g. N3)"
399                    }
400                },
401                "required": ["id"]
402            }),
403        }
404    }
405
406    async fn execute(&self, arguments: Value) -> Result<String> {
407        let id = arguments["id"]
408            .as_str()
409            .ok_or_else(|| Error::BadToolArgs {
410                name: "forget".into(),
411                message: "missing required parameter: id".to_string(),
412            })?
413            .to_string();
414
415        let _guard = self.lock.lock().unwrap();
416        let path = memory_path(&self.workspace);
417        let mut store = MemoryStore::load(&path)?;
418        if store.remove(&id) {
419            store.save(&path)?;
420            Ok(format!("removed {id}"))
421        } else {
422            Ok(format!("no such id: {id}"))
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use std::io::Write;
431
432    /// Helper: create a temporary workspace dir.
433    fn tmp_workspace() -> (tempfile::TempDir, PathBuf) {
434        let tmp = tempfile::TempDir::new().unwrap();
435        let ws = tmp.path().to_path_buf();
436        (tmp, ws)
437    }
438
439    #[test]
440    fn test_a_remember_then_recall() {
441        let (_tmp, ws) = tmp_workspace();
442        let remember = Remember::new(&ws);
443        let recall = Recall::new(&ws);
444
445        // Remember a note with tags
446        let result = tokio::runtime::Runtime::new()
447            .unwrap()
448            .block_on(remember.execute(json!({
449                "text": "When testing reqwest-based code, always set explicit timeouts",
450                "tags": ["rust", "testing"]
451            })));
452        assert!(result.is_ok());
453        let msg = result.unwrap();
454        assert!(msg.starts_with("saved note N"), "got: {msg}");
455
456        // Recall by query
457        let result = tokio::runtime::Runtime::new()
458            .unwrap()
459            .block_on(recall.execute(json!({"query": "reqwest"})));
460        assert!(result.is_ok());
461        let output = result.unwrap();
462        assert!(output.contains("N1"), "output: {output}");
463        assert!(output.contains("reqwest"), "output: {output}");
464        assert!(output.contains("[rust,testing]"), "output: {output}");
465
466        // Recall by tag
467        let result = tokio::runtime::Runtime::new()
468            .unwrap()
469            .block_on(recall.execute(json!({"tag": "rust"})));
470        assert!(result.is_ok());
471        let output = result.unwrap();
472        assert!(output.contains("N1"));
473    }
474
475    #[test]
476    fn test_b_remember_forget_recall_empty() {
477        let (_tmp, ws) = tmp_workspace();
478        let remember = Remember::new(&ws);
479        let forget = Forget::new(&ws);
480        let recall = Recall::new(&ws);
481
482        // Remember
483        tokio::runtime::Runtime::new()
484            .unwrap()
485            .block_on(remember.execute(json!({"text": "test note"})))
486            .unwrap();
487
488        // Forget
489        let result = tokio::runtime::Runtime::new()
490            .unwrap()
491            .block_on(forget.execute(json!({"id": "N1"})));
492        assert!(result.is_ok());
493        assert_eq!(result.unwrap(), "removed N1");
494
495        // Recall should be empty
496        let result = tokio::runtime::Runtime::new()
497            .unwrap()
498            .block_on(recall.execute(json!({})));
499        assert!(result.is_ok());
500        assert_eq!(result.unwrap(), "no matching notes found");
501    }
502
503    #[test]
504    fn test_c_malformed_json_errors_cleanly() {
505        let (_tmp, ws) = tmp_workspace();
506        let path = memory_path(&ws);
507        // Write invalid JSON
508        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
509        let mut f = std::fs::File::create(&path).unwrap();
510        writeln!(f, "this is not json").unwrap();
511        drop(f);
512
513        let recall = Recall::new(&ws);
514        let result = tokio::runtime::Runtime::new()
515            .unwrap()
516            .block_on(recall.execute(json!({})));
517        assert!(result.is_err());
518        let err = result.unwrap_err();
519        let msg = err.to_string();
520        assert!(msg.contains("malformed memory file"), "got: {msg}");
521    }
522
523    #[test]
524    fn test_no_memory_file_returns_empty() {
525        let (_tmp, ws) = tmp_workspace();
526        let recall = Recall::new(&ws);
527        let result = tokio::runtime::Runtime::new()
528            .unwrap()
529            .block_on(recall.execute(json!({})));
530        assert!(result.is_ok());
531        assert_eq!(result.unwrap(), "no matching notes found");
532    }
533
534    #[test]
535    fn test_remember_no_tags() {
536        let (_tmp, ws) = tmp_workspace();
537        let remember = Remember::new(&ws);
538        let recall = Recall::new(&ws);
539
540        tokio::runtime::Runtime::new()
541            .unwrap()
542            .block_on(remember.execute(json!({"text": "plain note"})))
543            .unwrap();
544
545        let result = tokio::runtime::Runtime::new()
546            .unwrap()
547            .block_on(recall.execute(json!({"query": "plain"})));
548        assert!(result.is_ok());
549        let output = result.unwrap();
550        assert!(output.contains("N1"));
551        // No tags bracket
552        assert!(!output.contains("[]"));
553    }
554
555    #[test]
556    fn test_forget_nonexistent_id() {
557        let (_tmp, ws) = tmp_workspace();
558        let forget = Forget::new(&ws);
559        let result = tokio::runtime::Runtime::new()
560            .unwrap()
561            .block_on(forget.execute(json!({"id": "N99"})));
562        assert!(result.is_ok());
563        assert_eq!(result.unwrap(), "no such id: N99");
564    }
565
566    #[test]
567    fn test_memory_summary_empty() {
568        let (_tmp, ws) = tmp_workspace();
569        let summary = memory_summary(&ws, 5);
570        assert_eq!(summary, "");
571    }
572
573    #[test]
574    fn test_memory_summary_with_notes() {
575        let (_tmp, ws) = tmp_workspace();
576        let remember = Remember::new(&ws);
577        tokio::runtime::Runtime::new()
578            .unwrap()
579            .block_on(remember.execute(json!({"text": "first note", "tags": ["a"]})))
580            .unwrap();
581        tokio::runtime::Runtime::new()
582            .unwrap()
583            .block_on(remember.execute(json!({"text": "second note", "tags": ["b"]})))
584            .unwrap();
585
586        let summary = memory_summary(&ws, 5);
587        assert!(!summary.is_empty());
588        assert!(summary.contains("N2"));
589        assert!(summary.contains("second note"));
590        assert!(summary.contains("N1"));
591        assert!(summary.contains("first note"));
592        // N2 should appear before N1 (most recent first)
593        let n2_pos = summary.find("N2").unwrap();
594        let n1_pos = summary.find("N1").unwrap();
595        assert!(n2_pos < n1_pos, "most recent note should come first");
596    }
597
598    #[test]
599    fn test_days_to_date() {
600        // Unix epoch
601        assert_eq!(days_to_date(0), (1970, 1, 1));
602        // 2026-05-25 (approx 20593 days from epoch)
603        let may_25_2026 = days_between(1970, 1, 1, 2026, 5, 25);
604        assert_eq!(days_to_date(may_25_2026), (2026, 5, 25));
605    }
606
607    /// Count days between two dates (naive, for testing).
608    fn days_between(y1: u64, m1: u64, d1: u64, y2: u64, m2: u64, d2: u64) -> u64 {
609        fn days_since_epoch(y: u64, m: u64, d: u64) -> u64 {
610            let mut total = 0u64;
611            for yr in 1970..y {
612                total += if is_leap(yr) { 366 } else { 365 };
613            }
614            let months_days: [u64; 12] = if is_leap(y) {
615                [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
616            } else {
617                [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
618            };
619            for md in months_days.iter().take((m - 1) as usize) {
620                total += md;
621            }
622            total += d - 1;
623            total
624        }
625        days_since_epoch(y2, m2, d2) - days_since_epoch(y1, m1, d1)
626    }
627}