1use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18use std::path::PathBuf;
19use std::sync::{Arc, Mutex};
20
21use crate::error::{Error, Result};
22use crate::llm::ToolSpec;
23use crate::memory::{EmbeddingProvider, MemoryEntry, NoopEmbedding, NoopVectorStore, VectorStore};
24use crate::tools::Tool;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Note {
29 pub id: String,
30 #[serde(default)]
31 pub tags: Vec<String>,
32 pub text: String,
33 pub ts: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct MemoryStore {
39 pub notes: Vec<Note>,
40}
41
42impl MemoryStore {
43 fn load(path: &std::path::Path) -> Result<Self> {
45 if !path.exists() {
46 return Ok(Self::default());
47 }
48 let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
49 name: "memory".into(),
50 message: format!("failed to read memory file: {e}"),
51 })?;
52 serde_json::from_str(&raw).map_err(|e| Error::Tool {
53 name: "memory".into(),
54 message: format!("malformed memory file: {e}"),
55 })
56 }
57
58 fn save(&self, path: &std::path::Path) -> Result<()> {
60 if let Some(parent) = path.parent() {
61 std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
62 name: "memory".into(),
63 message: format!("failed to create memory directory: {e}"),
64 })?;
65 }
66 let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
67 name: "memory".into(),
68 message: format!("failed to serialize memory: {e}"),
69 })?;
70 std::fs::write(path, raw).map_err(|e| Error::Tool {
71 name: "memory".into(),
72 message: format!("failed to write memory file: {e}"),
73 })?;
74 Ok(())
75 }
76
77 fn next_id(&self) -> String {
79 let max = self
80 .notes
81 .iter()
82 .filter_map(|n| n.id.strip_prefix('N'))
83 .filter_map(|s| s.parse::<u32>().ok())
84 .max()
85 .unwrap_or(0);
86 format!("N{}", max + 1)
87 }
88
89 fn add(&mut self, text: String, tags: Vec<String>) -> String {
91 let id = self.next_id();
92 let ts = chrono_now_rfc3339();
93 self.notes.push(Note {
94 id: id.clone(),
95 tags,
96 text,
97 ts,
98 });
99 id
100 }
101
102 fn remove(&mut self, id: &str) -> bool {
104 let before = self.notes.len();
105 self.notes.retain(|n| n.id != id);
106 self.notes.len() < before
107 }
108
109 fn search(&self, query: Option<&str>, tag: Option<&str>, limit: usize) -> Vec<&Note> {
112 let mut results: Vec<&Note> = self
113 .notes
114 .iter()
115 .filter(|n| {
116 let matches_query = query.map_or(true, |q| {
117 let q_lower = q.to_lowercase();
118 n.text.to_lowercase().contains(&q_lower)
119 || n.tags.iter().any(|t| t.to_lowercase().contains(&q_lower))
120 });
121 let matches_tag = tag.map_or(true, |t| n.tags.iter().any(|nt| nt == t));
122 matches_query && matches_tag
123 })
124 .collect();
125 results.reverse();
127 results.truncate(limit);
128 results
129 }
130}
131
132fn chrono_now_rfc3339() -> String {
134 let dur = std::time::SystemTime::now()
136 .duration_since(std::time::UNIX_EPOCH)
137 .unwrap_or_default();
138 let secs = dur.as_secs();
139 let days = secs / 86400;
141 let time_secs = secs % 86400;
142 let hours = time_secs / 3600;
143 let minutes = (time_secs % 3600) / 60;
144 let seconds = time_secs % 60;
145
146 let (year, month, day) = days_to_date(days);
148 format!(
149 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
150 year, month, day, hours, minutes, seconds
151 )
152}
153
154fn days_to_date(mut days: u64) -> (u64, u64, u64) {
157 let mut year: u64 = 1970;
159 loop {
160 let days_in_year = if is_leap(year) { 366 } else { 365 };
161 if days < days_in_year {
162 break;
163 }
164 days -= days_in_year;
165 year += 1;
166 }
167 let months_days: [u64; 12] = if is_leap(year) {
168 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
169 } else {
170 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
171 };
172 let mut month: u64 = 1;
173 for &md in &months_days {
174 if days < md {
175 break;
176 }
177 days -= md;
178 month += 1;
179 }
180 let day = days + 1; (year, month, day)
182}
183
184fn is_leap(year: u64) -> bool {
185 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
186}
187
188pub fn memory_path(workspace: &std::path::Path) -> PathBuf {
190 if std::env::var("RECURSIVE_MEMORY_GLOBAL").as_deref() == Ok("1") {
191 if let Some(home) = std::env::var_os("HOME") {
192 return PathBuf::from(home).join(".recursive").join("memory.json");
193 }
194 }
195 workspace.join(".recursive").join("memory.json")
196}
197
198pub fn load_memory(workspace: &std::path::Path) -> Result<MemoryStore> {
200 let path = memory_path(workspace);
201 MemoryStore::load(&path)
202}
203
204pub fn memory_summary(workspace: &std::path::Path, limit: usize) -> String {
208 let store = match load_memory(workspace) {
209 Ok(s) => s,
210 Err(_) => return String::new(),
211 };
212 if store.notes.is_empty() {
213 return String::new();
214 }
215 let mut lines: Vec<String> = Vec::new();
216 lines.push(format!(
217 "# Memory (top {} most recent notes; use `recall` for more)",
218 limit
219 ));
220 let mut notes: Vec<&Note> = store.notes.iter().collect();
222 notes.reverse();
223 for note in notes.iter().take(limit) {
224 let tags_str = if note.tags.is_empty() {
225 String::new()
226 } else {
227 format!(" [{}]", note.tags.join(","))
228 };
229 let text_preview = if note.text.len() > 120 {
231 format!("{}...", crate::truncate_str(¬e.text, 117))
232 } else {
233 note.text.clone()
234 };
235 lines.push(format!("- {}{} {}", note.id, tags_str, text_preview));
236 }
237 lines.join("\n")
238}
239
240pub struct Remember {
245 workspace: PathBuf,
246 lock: Mutex<()>,
248 vector_store: Arc<dyn VectorStore>,
250 embedding_provider: Arc<dyn EmbeddingProvider>,
252}
253
254impl Remember {
255 pub fn new(workspace: impl Into<PathBuf>) -> Self {
256 Self {
257 workspace: workspace.into(),
258 lock: Mutex::new(()),
259 vector_store: Arc::new(NoopVectorStore::new()),
260 embedding_provider: Arc::new(NoopEmbedding),
261 }
262 }
263
264 pub fn with_vector_store(
269 mut self,
270 store: Arc<dyn VectorStore>,
271 embedding: Arc<dyn EmbeddingProvider>,
272 ) -> Self {
273 self.vector_store = store;
274 self.embedding_provider = embedding;
275 self
276 }
277}
278
279#[async_trait]
280impl Tool for Remember {
281 fn spec(&self) -> ToolSpec {
282 ToolSpec {
283 name: "remember".into(),
284 description: "Save a note to persistent memory. The note will be available in future sessions via `recall` or injected into the system prompt.".into(),
285 parameters: json!({
286 "type": "object",
287 "properties": {
288 "text": {
289 "type": "string",
290 "description": "The note text to remember"
291 },
292 "tags": {
293 "type": "array",
294 "items": {"type": "string"},
295 "description": "Optional tags for categorising the note"
296 }
297 },
298 "required": ["text"]
299 }),
300 }
301 }
302
303 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
304 crate::tools::ToolSideEffect::Mutating
305 }
306
307 async fn execute(&self, arguments: Value) -> Result<String> {
308 let text = arguments["text"]
309 .as_str()
310 .ok_or_else(|| Error::BadToolArgs {
311 name: "remember".into(),
312 message: "missing required parameter: text".to_string(),
313 })?
314 .to_string();
315
316 let tags: Vec<String> = arguments["tags"]
317 .as_array()
318 .map(|arr| {
319 arr.iter()
320 .filter_map(|v| v.as_str().map(String::from))
321 .collect()
322 })
323 .unwrap_or_default();
324
325 let _guard = self.lock.lock().unwrap();
326 let path = memory_path(&self.workspace);
327 let mut file_store = MemoryStore::load(&path)?;
328 let id = file_store.add(text.clone(), tags.clone());
329 file_store.save(&path)?;
330 drop(_guard);
331
332 let ts = chrono::Utc::now().to_rfc3339();
334 let entry = MemoryEntry {
335 id: id.clone(),
336 text: text.clone(),
337 tags,
338 ts,
339 };
340 let vector = self.embedding_provider.embed(&text).await;
341 if let Err(e) = self.vector_store.upsert(&entry, vector).await {
342 tracing::warn!(error = %e, note_id = %id, "remember: vector upsert failed");
343 }
344
345 Ok(format!("saved note {id}"))
346 }
347}
348
349pub struct Recall {
350 workspace: PathBuf,
351 vector_store: Arc<dyn VectorStore>,
353 embedding_provider: Arc<dyn EmbeddingProvider>,
355}
356
357impl Recall {
358 pub fn new(workspace: impl Into<PathBuf>) -> Self {
359 Self {
360 workspace: workspace.into(),
361 vector_store: Arc::new(NoopVectorStore::new()),
362 embedding_provider: Arc::new(NoopEmbedding),
363 }
364 }
365
366 pub fn with_vector_store(
371 mut self,
372 store: Arc<dyn VectorStore>,
373 embedding: Arc<dyn EmbeddingProvider>,
374 ) -> Self {
375 self.vector_store = store;
376 self.embedding_provider = embedding;
377 self
378 }
379}
380
381#[async_trait]
382impl Tool for Recall {
383 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
384 crate::tools::ToolSideEffect::ReadOnly
385 }
386
387 fn spec(&self) -> ToolSpec {
388 ToolSpec {
389 name: "recall".into(),
390 description: "Search persistent memory for notes matching a query or tag. Returns up to `limit` results, most recent first.".into(),
391 parameters: json!({
392 "type": "object",
393 "properties": {
394 "query": {
395 "type": "string",
396 "description": "Case-insensitive substring to search for in note text or tags"
397 },
398 "tag": {
399 "type": "string",
400 "description": "Exact tag to filter by"
401 },
402 "limit": {
403 "type": "integer",
404 "description": "Maximum number of results (default 10)",
405 "default": 10
406 }
407 }
408 }),
409 }
410 }
411
412 async fn execute(&self, arguments: Value) -> Result<String> {
413 let query = arguments["query"].as_str().unwrap_or("");
414 let tag = arguments["tag"].as_str();
415 let limit = arguments["limit"].as_i64().unwrap_or(10) as usize;
416
417 let query_vec = self.embedding_provider.embed(query).await;
420 let use_vector = !query_vec.is_empty();
421
422 if use_vector || tag.is_none() {
423 match self.vector_store.search(query_vec, query, limit).await {
425 Ok(entries) if !entries.is_empty() => {
426 let filtered: Vec<_> = entries
428 .iter()
429 .filter(|e| tag.map_or(true, |t| e.tags.iter().any(|et| et == t)))
430 .take(limit)
431 .collect();
432 if !filtered.is_empty() {
433 let lines: Vec<String> = filtered
434 .iter()
435 .map(|e| {
436 let tags_str = if e.tags.is_empty() {
437 String::new()
438 } else {
439 format!(" [{}]", e.tags.join(","))
440 };
441 format!("{}{} {}", e.id, tags_str, e.text)
442 })
443 .collect();
444 return Ok(lines.join("\n"));
445 }
446 }
447 Ok(_) => {}
448 Err(e) => {
449 tracing::warn!(error = %e, "recall: vector search failed, falling back to file");
450 }
451 }
452 }
453
454 let path = memory_path(&self.workspace);
456 let file_store = MemoryStore::load(&path)?;
457 let query_opt = if query.is_empty() { None } else { Some(query) };
458 let results = file_store.search(query_opt, tag, limit);
459
460 if results.is_empty() {
461 return Ok("no matching notes found".to_string());
462 }
463
464 let lines: Vec<String> = results
465 .iter()
466 .map(|n| {
467 let tags_str = if n.tags.is_empty() {
468 String::new()
469 } else {
470 format!(" [{}]", n.tags.join(","))
471 };
472 format!("{}{} {}", n.id, tags_str, n.text)
473 })
474 .collect();
475
476 Ok(lines.join("\n"))
477 }
478}
479
480pub struct Forget {
481 workspace: PathBuf,
482 lock: Mutex<()>,
483}
484
485impl Forget {
486 pub fn new(workspace: impl Into<PathBuf>) -> Self {
487 Self {
488 workspace: workspace.into(),
489 lock: Mutex::new(()),
490 }
491 }
492}
493
494#[async_trait]
495impl Tool for Forget {
496 fn spec(&self) -> ToolSpec {
497 ToolSpec {
498 name: "forget".into(),
499 description: "Remove a note from persistent memory by its ID.".into(),
500 parameters: json!({
501 "type": "object",
502 "properties": {
503 "id": {
504 "type": "string",
505 "description": "The ID of the note to remove (e.g. N3)"
506 }
507 },
508 "required": ["id"]
509 }),
510 }
511 }
512
513 async fn execute(&self, arguments: Value) -> Result<String> {
514 let id = arguments["id"]
515 .as_str()
516 .ok_or_else(|| Error::BadToolArgs {
517 name: "forget".into(),
518 message: "missing required parameter: id".to_string(),
519 })?
520 .to_string();
521
522 let _guard = self.lock.lock().unwrap();
523 let path = memory_path(&self.workspace);
524 let mut store = MemoryStore::load(&path)?;
525 if store.remove(&id) {
526 store.save(&path)?;
527 Ok(format!("removed {id}"))
528 } else {
529 Ok(format!("no such id: {id}"))
530 }
531 }
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct ScratchpadEntry {
541 pub key: String,
542 pub value: String,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize, Default)]
547pub struct Scratchpad {
548 pub entries: Vec<ScratchpadEntry>,
549}
550
551impl Scratchpad {
552 fn load(path: &std::path::Path) -> Result<Self> {
554 if !path.exists() {
555 return Ok(Self::default());
556 }
557 let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
558 name: "scratchpad".into(),
559 message: format!("failed to read scratchpad file: {e}"),
560 })?;
561 serde_json::from_str(&raw).map_err(|e| Error::Tool {
562 name: "scratchpad".into(),
563 message: format!("malformed scratchpad file: {e}"),
564 })
565 }
566
567 fn save(&self, path: &std::path::Path) -> Result<()> {
569 if let Some(parent) = path.parent() {
570 std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
571 name: "scratchpad".into(),
572 message: format!("failed to create scratchpad directory: {e}"),
573 })?;
574 }
575 let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
576 name: "scratchpad".into(),
577 message: format!("failed to serialize scratchpad: {e}"),
578 })?;
579 std::fs::write(path, raw).map_err(|e| Error::Tool {
580 name: "scratchpad".into(),
581 message: format!("failed to write scratchpad file: {e}"),
582 })?;
583 Ok(())
584 }
585
586 fn set(&mut self, key: String, value: String) {
588 if let Some(existing) = self.entries.iter_mut().find(|e| e.key == key) {
590 existing.value = value;
591 } else {
592 self.entries.push(ScratchpadEntry { key, value });
593 }
594 }
595
596 fn get(&self, key: &str) -> Option<&str> {
598 self.entries
599 .iter()
600 .find(|e| e.key == key)
601 .map(|e| e.value.as_str())
602 }
603
604 fn delete(&mut self, key: &str) -> bool {
606 let before = self.entries.len();
607 self.entries.retain(|e| e.key != key);
608 self.entries.len() < before
609 }
610
611 fn keys(&self) -> Vec<&str> {
613 self.entries.iter().map(|e| e.key.as_str()).collect()
614 }
615}
616
617pub fn scratchpad_path(workspace: &std::path::Path) -> PathBuf {
621 crate::paths::user_scratchpad_path(workspace)
622 .unwrap_or_else(|_| workspace.join(".recursive").join("scratchpad.json"))
623}
624
625pub fn load_scratchpad(workspace: &std::path::Path) -> Result<Scratchpad> {
627 let path = scratchpad_path(workspace);
628 Scratchpad::load(&path)
629}
630
631pub fn scratchpad_summary(workspace: &std::path::Path) -> String {
635 let pad = match load_scratchpad(workspace) {
636 Ok(p) => p,
637 Err(_) => return String::new(),
638 };
639 if pad.entries.is_empty() {
640 return String::new();
641 }
642 let mut lines: Vec<String> = Vec::new();
643 lines.push("# Working Memory (scratchpad)".to_string());
644 for entry in &pad.entries {
645 let value_preview = if entry.value.len() > 200 {
647 format!("{}...", crate::truncate_str(&entry.value, 197))
648 } else {
649 entry.value.clone()
650 };
651 lines.push(format!("- {}: {}", entry.key, value_preview));
652 }
653 lines.join("\n")
654}
655
656pub fn migrate_scratchpad(_workspace: &std::path::Path) -> Result<()> {
659 Ok(())
661}
662
663pub struct WorkingMemoryTool {
668 workspace: PathBuf,
669 lock: Mutex<()>,
670}
671
672impl WorkingMemoryTool {
673 pub fn new(workspace: impl Into<PathBuf>) -> Self {
674 Self {
675 workspace: workspace.into(),
676 lock: Mutex::new(()),
677 }
678 }
679}
680
681#[async_trait]
682impl Tool for WorkingMemoryTool {
683 fn spec(&self) -> ToolSpec {
684 ToolSpec {
685 name: "scratchpad_set".into(),
686 description: "Store a value in working memory (scratchpad) under a key. Use this to remember intermediate results, decisions, or context across steps. The scratchpad contents are injected into the system prompt.".into(),
687 parameters: json!({
688 "type": "object",
689 "properties": {
690 "key": {
691 "type": "string",
692 "description": "The key to store under"
693 },
694 "value": {
695 "type": "string",
696 "description": "The value to store"
697 }
698 },
699 "required": ["key", "value"]
700 }),
701 }
702 }
703
704 async fn execute(&self, arguments: Value) -> Result<String> {
705 let key = arguments["key"]
706 .as_str()
707 .ok_or_else(|| Error::BadToolArgs {
708 name: "scratchpad_set".into(),
709 message: "missing required parameter: key".to_string(),
710 })?
711 .to_string();
712 let value = arguments["value"]
713 .as_str()
714 .ok_or_else(|| Error::BadToolArgs {
715 name: "scratchpad_set".into(),
716 message: "missing required parameter: value".to_string(),
717 })?
718 .to_string();
719
720 let _guard = self.lock.lock().unwrap();
721 let path = scratchpad_path(&self.workspace);
722 let mut pad = Scratchpad::load(&path)?;
723 pad.set(key.clone(), value);
724 pad.save(&path)?;
725 Ok(format!("scratchpad key '{key}' set"))
726 }
727}
728
729pub struct ScratchpadGet {
733 workspace: PathBuf,
734}
735
736impl ScratchpadGet {
737 pub fn new(workspace: impl Into<PathBuf>) -> Self {
738 Self {
739 workspace: workspace.into(),
740 }
741 }
742}
743
744#[async_trait]
745impl Tool for ScratchpadGet {
746 fn spec(&self) -> ToolSpec {
747 ToolSpec {
748 name: "scratchpad_get".into(),
749 description: "Retrieve a value from working memory (scratchpad) by key.".into(),
750 parameters: json!({
751 "type": "object",
752 "properties": {
753 "key": {
754 "type": "string",
755 "description": "The key to retrieve"
756 }
757 },
758 "required": ["key"]
759 }),
760 }
761 }
762
763 async fn execute(&self, arguments: Value) -> Result<String> {
764 let key = arguments["key"]
765 .as_str()
766 .ok_or_else(|| Error::BadToolArgs {
767 name: "scratchpad_get".into(),
768 message: "missing required parameter: key".to_string(),
769 })?
770 .to_string();
771
772 let path = scratchpad_path(&self.workspace);
773 let pad = Scratchpad::load(&path)?;
774 match pad.get(&key) {
775 Some(value) => Ok(value.to_string()),
776 None => Ok(format!("no such key: {key}")),
777 }
778 }
779}
780
781pub struct ScratchpadDelete {
782 workspace: PathBuf,
783 lock: Mutex<()>,
784}
785
786impl ScratchpadDelete {
787 pub fn new(workspace: impl Into<PathBuf>) -> Self {
788 Self {
789 workspace: workspace.into(),
790 lock: Mutex::new(()),
791 }
792 }
793}
794
795#[async_trait]
796impl Tool for ScratchpadDelete {
797 fn spec(&self) -> ToolSpec {
798 ToolSpec {
799 name: "scratchpad_delete".into(),
800 description: "Delete a key from working memory (scratchpad).".into(),
801 parameters: json!({
802 "type": "object",
803 "properties": {
804 "key": {
805 "type": "string",
806 "description": "The key to delete"
807 }
808 },
809 "required": ["key"]
810 }),
811 }
812 }
813
814 async fn execute(&self, arguments: Value) -> Result<String> {
815 let key = arguments["key"]
816 .as_str()
817 .ok_or_else(|| Error::BadToolArgs {
818 name: "scratchpad_delete".into(),
819 message: "missing required parameter: key".to_string(),
820 })?
821 .to_string();
822
823 let _guard = self.lock.lock().unwrap();
824 let path = scratchpad_path(&self.workspace);
825 let mut pad = Scratchpad::load(&path)?;
826 if pad.delete(&key) {
827 pad.save(&path)?;
828 Ok(format!("scratchpad key '{key}' deleted"))
829 } else {
830 Ok(format!("no such key: {key}"))
831 }
832 }
833}
834
835pub struct ScratchpadList {
836 workspace: PathBuf,
837}
838
839impl ScratchpadList {
840 pub fn new(workspace: impl Into<PathBuf>) -> Self {
841 Self {
842 workspace: workspace.into(),
843 }
844 }
845}
846
847#[async_trait]
848impl Tool for ScratchpadList {
849 fn spec(&self) -> ToolSpec {
850 ToolSpec {
851 name: "scratchpad_list".into(),
852 description: "List all keys currently stored in working memory (scratchpad).".into(),
853 parameters: json!({
854 "type": "object",
855 "properties": {}
856 }),
857 }
858 }
859
860 async fn execute(&self, _arguments: Value) -> Result<String> {
861 let path = scratchpad_path(&self.workspace);
862 let pad = Scratchpad::load(&path)?;
863 let keys = pad.keys();
864 if keys.is_empty() {
865 return Ok("scratchpad is empty".to_string());
866 }
867 Ok(keys.join("\n"))
868 }
869}