1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct MemoryStore {
33 pub notes: Vec<Note>,
34}
35
36impl MemoryStore {
37 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 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 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 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 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 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 results.reverse();
121 results.truncate(limit);
122 results
123 }
124}
125
126fn chrono_now_rfc3339() -> String {
128 let dur = std::time::SystemTime::now()
130 .duration_since(std::time::UNIX_EPOCH)
131 .unwrap_or_default();
132 let secs = dur.as_secs();
133 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 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
148fn days_to_date(mut days: u64) -> (u64, u64, u64) {
151 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; (year, month, day)
176}
177
178fn is_leap(year: u64) -> bool {
179 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
180}
181
182pub 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
192pub fn load_memory(workspace: &std::path::Path) -> Result<MemoryStore> {
194 let path = memory_path(workspace);
195 MemoryStore::load(&path)
196}
197
198pub 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 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 let text_preview = if note.text.len() > 120 {
222 format!("{}...", ¬e.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
231pub struct Remember {
236 workspace: PathBuf,
237 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 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 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 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 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 tokio::runtime::Runtime::new()
484 .unwrap()
485 .block_on(remember.execute(json!({"text": "test note"})))
486 .unwrap();
487
488 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 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 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 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 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 assert_eq!(days_to_date(0), (1970, 1, 1));
602 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 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}