Skip to main content

recursive/tools/
memory.rs

1//! Persistent memory tools: `remember`, `recall`, `forget`.
2//!
3//! Also provides a scratchpad (working memory) layer: `scratchpad_set`,
4//! `scratchpad_get`, `scratchpad_delete`, `scratchpad_list`. The scratchpad
5//! is stored in `<workspace>/.recursive/scratchpad.json` and its contents
6//! are injected into the system prompt as a summary.
7//!
8//! Notes are stored in `<workspace>/.recursive/memory.json` (or
9//! `~/.recursive/memory.json` if `RECURSIVE_MEMORY_GLOBAL=1`).
10//! Schema:
11//! ```json
12//! { "notes": [ { "id": "N1", "tags": ["rust"], "text": "...", "ts": "..." } ] }
13//! ```
14
15use 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/// A single memory note.
27#[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/// The on-disk store.
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct MemoryStore {
39    pub notes: Vec<Note>,
40}
41
42impl MemoryStore {
43    /// Load from a path, returning an empty store if the file doesn't exist.
44    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            call_id: None,
51            message: format!("failed to read memory file: {e}"),
52        })?;
53        serde_json::from_str(&raw).map_err(|e| Error::Tool {
54            name: "memory".into(),
55            call_id: None,
56            message: format!("malformed memory file: {e}"),
57        })
58    }
59
60    /// Save to disk, creating parent directories if needed.
61    fn save(&self, path: &std::path::Path) -> Result<()> {
62        if let Some(parent) = path.parent() {
63            std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
64                name: "memory".into(),
65                call_id: None,
66                message: format!("failed to create memory directory: {e}"),
67            })?;
68        }
69        let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
70            name: "memory".into(),
71            call_id: None,
72            message: format!("failed to serialize memory: {e}"),
73        })?;
74        std::fs::write(path, raw).map_err(|e| Error::Tool {
75            name: "memory".into(),
76            call_id: None,
77            message: format!("failed to write memory file: {e}"),
78        })?;
79        Ok(())
80    }
81
82    /// Generate the next monotonic ID.
83    fn next_id(&self) -> String {
84        let max = self
85            .notes
86            .iter()
87            .filter_map(|n| n.id.strip_prefix('N'))
88            .filter_map(|s| s.parse::<u32>().ok())
89            .max()
90            .unwrap_or(0);
91        format!("N{}", max + 1)
92    }
93
94    /// Add a note, returning its ID.
95    fn add(&mut self, text: String, tags: Vec<String>) -> String {
96        let id = self.next_id();
97        let ts = chrono_now_rfc3339();
98        self.notes.push(Note {
99            id: id.clone(),
100            tags,
101            text,
102            ts,
103        });
104        id
105    }
106
107    /// Remove a note by ID. Returns true if found.
108    fn remove(&mut self, id: &str) -> bool {
109        let before = self.notes.len();
110        self.notes.retain(|n| n.id != id);
111        self.notes.len() < before
112    }
113
114    /// Search notes by query (case-insensitive substring in text or tags)
115    /// or by exact tag match. Returns up to `limit` results, most recent first.
116    fn search(&self, query: Option<&str>, tag: Option<&str>, limit: usize) -> Vec<&Note> {
117        let mut results: Vec<&Note> = self
118            .notes
119            .iter()
120            .filter(|n| {
121                let matches_query = query.map_or(true, |q| {
122                    let q_lower = q.to_lowercase();
123                    n.text.to_lowercase().contains(&q_lower)
124                        || n.tags.iter().any(|t| t.to_lowercase().contains(&q_lower))
125                });
126                let matches_tag = tag.map_or(true, |t| n.tags.iter().any(|nt| nt == t));
127                matches_query && matches_tag
128            })
129            .collect();
130        // Most recent first (reverse chronological)
131        results.reverse();
132        results.truncate(limit);
133        results
134    }
135}
136
137/// Get an RFC 3339 timestamp string.
138fn chrono_now_rfc3339() -> String {
139    // Use std::time to build a simple UTC timestamp without chrono dependency.
140    let dur = std::time::SystemTime::now()
141        .duration_since(std::time::UNIX_EPOCH)
142        .unwrap_or_default();
143    let secs = dur.as_secs();
144    // Format as ISO 8601 / RFC 3339
145    let days = secs / 86400;
146    let time_secs = secs % 86400;
147    let hours = time_secs / 3600;
148    let minutes = (time_secs % 3600) / 60;
149    let seconds = time_secs % 60;
150
151    // Compute year/month/day from days since epoch (simplified)
152    let (year, month, day) = days_to_date(days);
153    format!(
154        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
155        year, month, day, hours, minutes, seconds
156    )
157}
158
159/// Convert days since Unix epoch to (year, month, day).
160/// Uses a simple leap-year-aware algorithm.
161fn days_to_date(mut days: u64) -> (u64, u64, u64) {
162    // Start from 1970-01-01
163    let mut year: u64 = 1970;
164    loop {
165        let days_in_year = if is_leap(year) { 366 } else { 365 };
166        if days < days_in_year {
167            break;
168        }
169        days -= days_in_year;
170        year += 1;
171    }
172    let months_days: [u64; 12] = if is_leap(year) {
173        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
174    } else {
175        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
176    };
177    let mut month: u64 = 1;
178    for &md in &months_days {
179        if days < md {
180            break;
181        }
182        days -= md;
183        month += 1;
184    }
185    let day = days + 1; // 1-indexed
186    (year, month, day)
187}
188
189fn is_leap(year: u64) -> bool {
190    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
191}
192
193/// Determine the memory file path based on workspace and env var.
194pub fn memory_path(workspace: &std::path::Path) -> PathBuf {
195    if std::env::var("RECURSIVE_MEMORY_GLOBAL").as_deref() == Ok("1") {
196        if let Some(home) = std::env::var_os("HOME") {
197            return PathBuf::from(home).join(".recursive").join("memory.json");
198        }
199    }
200    workspace.join(".recursive").join("memory.json")
201}
202
203/// Load the memory store from the workspace-relative path.
204pub fn load_memory(workspace: &std::path::Path) -> Result<MemoryStore> {
205    let path = memory_path(workspace);
206    MemoryStore::load(&path)
207}
208
209/// Build a memory summary string for injection into the system prompt.
210/// Returns the top N most recent notes as an index-style listing, or empty
211/// string if no notes exist.
212///
213/// The output format mimics fake-cc's MEMORY.md index style:
214/// `- [ID](recall) — one-line hook`
215/// This keeps the summary compact and token-efficient while guiding the
216/// agent to use `recall` for full content when needed.
217pub fn memory_summary(workspace: &std::path::Path, limit: usize) -> String {
218    let store = match load_memory(workspace) {
219        Ok(s) => s,
220        Err(_) => return String::new(),
221    };
222    if store.notes.is_empty() {
223        return String::new();
224    }
225    let mut lines: Vec<String> = Vec::new();
226    lines.push("# Memory Index".to_string());
227    lines.push(format!(
228        "Top {} most recent notes. Use `recall` with the note ID to read full content.",
229        limit
230    ));
231    // Most recent first
232    let mut notes: Vec<&Note> = store.notes.iter().collect();
233    notes.reverse();
234    for note in notes.iter().take(limit) {
235        // Generate a concise "hook" from the note text
236        let hook = if note.text.len() > 80 {
237            format!("{}...", crate::truncate_str(&note.text, 77))
238        } else {
239            note.text.clone()
240        };
241        // Replace newlines with spaces to keep the index compact
242        let hook = hook.replace('\n', " ");
243        // Index-style format: `- [ID](recall) — hook`
244        // The "(recall)" indicates the tool to use for full content
245        lines.push(format!("- [{}](recall) — {}", note.id, hook));
246    }
247    lines.join("\n")
248}
249
250// ---------------------------------------------------------------------------
251// Tool implementations
252// ---------------------------------------------------------------------------
253
254pub struct Remember {
255    workspace: PathBuf,
256    /// Mutex for thread-safe access to the memory file.
257    lock: Mutex<()>,
258    /// Optional vector store for semantic indexing.
259    vector_store: Arc<dyn VectorStore>,
260    /// Optional embedding provider for generating vectors.
261    embedding_provider: Arc<dyn EmbeddingProvider>,
262}
263
264impl Remember {
265    pub fn new(workspace: impl Into<PathBuf>) -> Self {
266        Self {
267            workspace: workspace.into(),
268            lock: Mutex::new(()),
269            vector_store: Arc::new(NoopVectorStore::new()),
270            embedding_provider: Arc::new(NoopEmbedding),
271        }
272    }
273
274    /// Inject a vector store + embedding provider for semantic indexing.
275    ///
276    /// When set, every `remember` call will also embed the text and upsert the
277    /// vector into the store, enabling semantic `recall` queries.
278    pub fn with_vector_store(
279        mut self,
280        store: Arc<dyn VectorStore>,
281        embedding: Arc<dyn EmbeddingProvider>,
282    ) -> Self {
283        self.vector_store = store;
284        self.embedding_provider = embedding;
285        self
286    }
287}
288
289#[async_trait]
290impl Tool for Remember {
291    fn spec(&self) -> ToolSpec {
292        ToolSpec {
293            name: "remember".into(),
294            description: "Save a note to persistent memory. The note will be available in future sessions via `recall` or injected into the system prompt.".into(),
295            parameters: json!({
296                "type": "object",
297                "properties": {
298                    "text": {
299                        "type": "string",
300                        "description": "The note text to remember"
301                    },
302                    "tags": {
303                        "type": "array",
304                        "items": {"type": "string"},
305                        "description": "Optional tags for categorising the note"
306                    }
307                },
308                "required": ["text"]
309            }),
310        }
311    }
312
313    fn is_deferred(&self) -> bool {
314        true
315    }
316
317    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
318        crate::tools::ToolSideEffect::Mutating
319    }
320
321    async fn execute(&self, arguments: Value) -> Result<String> {
322        let text = arguments["text"]
323            .as_str()
324            .ok_or_else(|| Error::BadToolArgs {
325                name: "remember".into(),
326                message: "missing required parameter: text".to_string(),
327            })?
328            .to_string();
329
330        let tags: Vec<String> = arguments["tags"]
331            .as_array()
332            .map(|arr| {
333                arr.iter()
334                    .filter_map(|v| v.as_str().map(String::from))
335                    .collect()
336            })
337            .unwrap_or_default();
338
339        let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
340        let path = memory_path(&self.workspace);
341        let mut file_store = MemoryStore::load(&path)?;
342        let id = file_store.add(text.clone(), tags.clone());
343        file_store.save(&path)?;
344        drop(_guard);
345
346        // Also index in the vector store if one is configured.
347        let ts = chrono::Utc::now().to_rfc3339();
348        let entry = MemoryEntry {
349            id: id.clone(),
350            text: text.clone(),
351            tags,
352            ts,
353        };
354        let vector = self.embedding_provider.embed(&text).await;
355        if let Err(e) = self.vector_store.upsert(&entry, vector).await {
356            tracing::warn!(error = %e, note_id = %id, "remember: vector upsert failed");
357        }
358
359        Ok(format!("saved note {id}"))
360    }
361}
362
363pub struct Recall {
364    workspace: PathBuf,
365    /// Optional vector store for semantic retrieval.
366    vector_store: Arc<dyn VectorStore>,
367    /// Optional embedding provider for query vectorisation.
368    embedding_provider: Arc<dyn EmbeddingProvider>,
369}
370
371impl Recall {
372    pub fn new(workspace: impl Into<PathBuf>) -> Self {
373        Self {
374            workspace: workspace.into(),
375            vector_store: Arc::new(NoopVectorStore::new()),
376            embedding_provider: Arc::new(NoopEmbedding),
377        }
378    }
379
380    /// Inject a vector store + embedding provider for semantic retrieval.
381    ///
382    /// When set, `recall` will embed the query and perform cosine-similarity
383    /// search instead of keyword substring search.
384    pub fn with_vector_store(
385        mut self,
386        store: Arc<dyn VectorStore>,
387        embedding: Arc<dyn EmbeddingProvider>,
388    ) -> Self {
389        self.vector_store = store;
390        self.embedding_provider = embedding;
391        self
392    }
393}
394
395#[async_trait]
396impl Tool for Recall {
397    fn is_deferred(&self) -> bool {
398        true
399    }
400
401    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
402        crate::tools::ToolSideEffect::ReadOnly
403    }
404
405    fn spec(&self) -> ToolSpec {
406        ToolSpec {
407            name: "recall".into(),
408            description: "Search persistent memory for notes matching a query or tag. Returns up to `limit` results, most recent first.".into(),
409            parameters: json!({
410                "type": "object",
411                "properties": {
412                    "query": {
413                        "type": "string",
414                        "description": "Case-insensitive substring to search for in note text or tags"
415                    },
416                    "tag": {
417                        "type": "string",
418                        "description": "Exact tag to filter by"
419                    },
420                    "limit": {
421                        "type": "integer",
422                        "description": "Maximum number of results (default 10)",
423                        "default": 10
424                    }
425                }
426            }),
427        }
428    }
429
430    async fn execute(&self, arguments: Value) -> Result<String> {
431        let query = arguments["query"].as_str().unwrap_or("");
432        let tag = arguments["tag"].as_str();
433        let limit = arguments["limit"].as_i64().unwrap_or(10) as usize;
434
435        // Try vector search first; fall back to file-based keyword search
436        // when the vector store or embedding provider is a no-op.
437        let query_vec = self.embedding_provider.embed(query).await;
438        let use_vector = !query_vec.is_empty();
439
440        if use_vector || tag.is_none() {
441            // Use the vector store (semantic or keyword fallback inside the store).
442            match self.vector_store.search(query_vec, query, limit).await {
443                Ok(entries) if !entries.is_empty() => {
444                    // Apply tag filter if requested.
445                    let filtered: Vec<_> = entries
446                        .iter()
447                        .filter(|e| tag.map_or(true, |t| e.tags.iter().any(|et| et == t)))
448                        .take(limit)
449                        .collect();
450                    if !filtered.is_empty() {
451                        let lines: Vec<String> = filtered
452                            .iter()
453                            .map(|e| {
454                                let tags_str = if e.tags.is_empty() {
455                                    String::new()
456                                } else {
457                                    format!(" [{}]", e.tags.join(","))
458                                };
459                                format!("{}{} {}", e.id, tags_str, e.text)
460                            })
461                            .collect();
462                        return Ok(lines.join("\n"));
463                    }
464                }
465                Ok(_) => {}
466                Err(e) => {
467                    tracing::warn!(error = %e, "recall: vector search failed, falling back to file");
468                }
469            }
470        }
471
472        // File-based fallback (or when only tag filter is used).
473        let path = memory_path(&self.workspace);
474        let file_store = MemoryStore::load(&path)?;
475        let query_opt = if query.is_empty() { None } else { Some(query) };
476        let results = file_store.search(query_opt, tag, limit);
477
478        if results.is_empty() {
479            return Ok("no matching notes found".to_string());
480        }
481
482        let lines: Vec<String> = results
483            .iter()
484            .map(|n| {
485                let tags_str = if n.tags.is_empty() {
486                    String::new()
487                } else {
488                    format!(" [{}]", n.tags.join(","))
489                };
490                format!("{}{} {}", n.id, tags_str, n.text)
491            })
492            .collect();
493
494        Ok(lines.join("\n"))
495    }
496}
497
498pub struct Forget {
499    workspace: PathBuf,
500    lock: Mutex<()>,
501}
502
503impl Forget {
504    pub fn new(workspace: impl Into<PathBuf>) -> Self {
505        Self {
506            workspace: workspace.into(),
507            lock: Mutex::new(()),
508        }
509    }
510}
511
512#[async_trait]
513impl Tool for Forget {
514    fn is_deferred(&self) -> bool {
515        true
516    }
517
518    fn spec(&self) -> ToolSpec {
519        ToolSpec {
520            name: "forget".into(),
521            description: "Remove a note from persistent memory by its ID.".into(),
522            parameters: json!({
523                "type": "object",
524                "properties": {
525                    "id": {
526                        "type": "string",
527                        "description": "The ID of the note to remove (e.g. N3)"
528                    }
529                },
530                "required": ["id"]
531            }),
532        }
533    }
534
535    async fn execute(&self, arguments: Value) -> Result<String> {
536        let id = arguments["id"]
537            .as_str()
538            .ok_or_else(|| Error::BadToolArgs {
539                name: "forget".into(),
540                message: "missing required parameter: id".to_string(),
541            })?
542            .to_string();
543
544        let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
545        let path = memory_path(&self.workspace);
546        let mut store = MemoryStore::load(&path)?;
547        if store.remove(&id) {
548            store.save(&path)?;
549            Ok(format!("removed {id}"))
550        } else {
551            Ok(format!("no such id: {id}"))
552        }
553    }
554}
555
556// ---------------------------------------------------------------------------
557// Scratchpad (working memory)
558// ---------------------------------------------------------------------------
559
560/// A single scratchpad entry.
561#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct ScratchpadEntry {
563    pub key: String,
564    pub value: String,
565}
566
567/// The on-disk scratchpad store.
568#[derive(Debug, Clone, Serialize, Deserialize, Default)]
569pub struct Scratchpad {
570    pub entries: Vec<ScratchpadEntry>,
571}
572
573impl Scratchpad {
574    /// Load from a path, returning an empty scratchpad if the file doesn't exist.
575    fn load(path: &std::path::Path) -> Result<Self> {
576        if !path.exists() {
577            return Ok(Self::default());
578        }
579        let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
580            name: "scratchpad".into(),
581            call_id: None,
582            message: format!("failed to read scratchpad file: {e}"),
583        })?;
584        serde_json::from_str(&raw).map_err(|e| Error::Tool {
585            name: "scratchpad".into(),
586            call_id: None,
587            message: format!("malformed scratchpad file: {e}"),
588        })
589    }
590
591    /// Save to disk, creating parent directories if needed.
592    fn save(&self, path: &std::path::Path) -> Result<()> {
593        if let Some(parent) = path.parent() {
594            std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
595                name: "scratchpad".into(),
596                call_id: None,
597                message: format!("failed to create scratchpad directory: {e}"),
598            })?;
599        }
600        let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
601            name: "scratchpad".into(),
602            call_id: None,
603            message: format!("failed to serialize scratchpad: {e}"),
604        })?;
605        std::fs::write(path, raw).map_err(|e| Error::Tool {
606            name: "scratchpad".into(),
607            call_id: None,
608            message: format!("failed to write scratchpad file: {e}"),
609        })?;
610        Ok(())
611    }
612
613    /// Set a key-value pair (insert or update).
614    fn set(&mut self, key: String, value: String) {
615        // Replace existing entry with the same key, or add new one.
616        if let Some(existing) = self.entries.iter_mut().find(|e| e.key == key) {
617            existing.value = value;
618        } else {
619            self.entries.push(ScratchpadEntry { key, value });
620        }
621    }
622
623    /// Get the value for a key.
624    fn get(&self, key: &str) -> Option<&str> {
625        self.entries
626            .iter()
627            .find(|e| e.key == key)
628            .map(|e| e.value.as_str())
629    }
630
631    /// Delete an entry by key. Returns true if found.
632    fn delete(&mut self, key: &str) -> bool {
633        let before = self.entries.len();
634        self.entries.retain(|e| e.key != key);
635        self.entries.len() < before
636    }
637
638    /// List all keys.
639    fn keys(&self) -> Vec<&str> {
640        self.entries.iter().map(|e| e.key.as_str()).collect()
641    }
642}
643
644/// Determine the scratchpad file path. Lives under the per-user data
645/// dir so it doesn't pollute the project tree:
646/// `~/.recursive/workspaces/<ws-hash>/scratchpad.json`.
647pub fn scratchpad_path(workspace: &std::path::Path) -> PathBuf {
648    crate::paths::user_scratchpad_path(workspace)
649        .unwrap_or_else(|_| workspace.join(".recursive").join("scratchpad.json"))
650}
651
652/// Load the scratchpad from the workspace-relative path.
653pub fn load_scratchpad(workspace: &std::path::Path) -> Result<Scratchpad> {
654    let path = scratchpad_path(workspace);
655    Scratchpad::load(&path)
656}
657
658/// Build a scratchpad summary string for injection into the system prompt.
659/// Returns a formatted block of all key-value pairs, or empty string if
660/// the scratchpad is empty.
661pub fn scratchpad_summary(workspace: &std::path::Path) -> String {
662    let pad = match load_scratchpad(workspace) {
663        Ok(p) => p,
664        Err(_) => return String::new(),
665    };
666    if pad.entries.is_empty() {
667        return String::new();
668    }
669    let mut lines: Vec<String> = Vec::new();
670    lines.push("# Working Memory (scratchpad)".to_string());
671    for entry in &pad.entries {
672        // Truncate long values for the summary
673        let value_preview = if entry.value.len() > 200 {
674            format!("{}...", crate::truncate_str(&entry.value, 197))
675        } else {
676            entry.value.clone()
677        };
678        lines.push(format!("- {}: {}", entry.key, value_preview));
679    }
680    lines.join("\n")
681}
682
683/// Migrate old-format scratchpad data (if any) to the new format.
684/// Currently a no-op placeholder for future migration logic.
685pub fn migrate_scratchpad(_workspace: &std::path::Path) -> Result<()> {
686    // No old format to migrate from yet.
687    Ok(())
688}
689
690// ---------------------------------------------------------------------------
691// WorkingMemoryTool: exposes scratchpad operations as tools
692// ---------------------------------------------------------------------------
693
694pub struct WorkingMemoryTool {
695    workspace: PathBuf,
696    lock: Mutex<()>,
697}
698
699impl WorkingMemoryTool {
700    pub fn new(workspace: impl Into<PathBuf>) -> Self {
701        Self {
702            workspace: workspace.into(),
703            lock: Mutex::new(()),
704        }
705    }
706}
707
708#[async_trait]
709impl Tool for WorkingMemoryTool {
710    fn is_deferred(&self) -> bool {
711        true
712    }
713
714    fn spec(&self) -> ToolSpec {
715        ToolSpec {
716            name: "scratchpad_set".into(),
717            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(),
718            parameters: json!({
719                "type": "object",
720                "properties": {
721                    "key": {
722                        "type": "string",
723                        "description": "The key to store under"
724                    },
725                    "value": {
726                        "type": "string",
727                        "description": "The value to store"
728                    }
729                },
730                "required": ["key", "value"]
731            }),
732        }
733    }
734
735    async fn execute(&self, arguments: Value) -> Result<String> {
736        let key = arguments["key"]
737            .as_str()
738            .ok_or_else(|| Error::BadToolArgs {
739                name: "scratchpad_set".into(),
740                message: "missing required parameter: key".to_string(),
741            })?
742            .to_string();
743        let value = arguments["value"]
744            .as_str()
745            .ok_or_else(|| Error::BadToolArgs {
746                name: "scratchpad_set".into(),
747                message: "missing required parameter: value".to_string(),
748            })?
749            .to_string();
750
751        let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
752        let path = scratchpad_path(&self.workspace);
753        let mut pad = Scratchpad::load(&path)?;
754        pad.set(key.clone(), value);
755        pad.save(&path)?;
756        Ok(format!("scratchpad key '{key}' set"))
757    }
758}
759
760/// Helper: dispatch scratchpad operations based on the tool name.
761/// This is used by the multi-tool approach where one struct handles
762/// multiple tool names.
763pub struct ScratchpadGet {
764    workspace: PathBuf,
765}
766
767impl ScratchpadGet {
768    pub fn new(workspace: impl Into<PathBuf>) -> Self {
769        Self {
770            workspace: workspace.into(),
771        }
772    }
773}
774
775#[async_trait]
776impl Tool for ScratchpadGet {
777    fn is_deferred(&self) -> bool {
778        true
779    }
780
781    fn spec(&self) -> ToolSpec {
782        ToolSpec {
783            name: "scratchpad_get".into(),
784            description: "Retrieve a value from working memory (scratchpad) by key.".into(),
785            parameters: json!({
786                "type": "object",
787                "properties": {
788                    "key": {
789                        "type": "string",
790                        "description": "The key to retrieve"
791                    }
792                },
793                "required": ["key"]
794            }),
795        }
796    }
797
798    async fn execute(&self, arguments: Value) -> Result<String> {
799        let key = arguments["key"]
800            .as_str()
801            .ok_or_else(|| Error::BadToolArgs {
802                name: "scratchpad_get".into(),
803                message: "missing required parameter: key".to_string(),
804            })?
805            .to_string();
806
807        let path = scratchpad_path(&self.workspace);
808        let pad = Scratchpad::load(&path)?;
809        match pad.get(&key) {
810            Some(value) => Ok(value.to_string()),
811            None => Ok(format!("no such key: {key}")),
812        }
813    }
814}
815
816pub struct ScratchpadDelete {
817    workspace: PathBuf,
818    lock: Mutex<()>,
819}
820
821impl ScratchpadDelete {
822    pub fn new(workspace: impl Into<PathBuf>) -> Self {
823        Self {
824            workspace: workspace.into(),
825            lock: Mutex::new(()),
826        }
827    }
828}
829
830#[async_trait]
831impl Tool for ScratchpadDelete {
832    fn is_deferred(&self) -> bool {
833        true
834    }
835
836    fn spec(&self) -> ToolSpec {
837        ToolSpec {
838            name: "scratchpad_delete".into(),
839            description: "Delete a key from working memory (scratchpad).".into(),
840            parameters: json!({
841                "type": "object",
842                "properties": {
843                    "key": {
844                        "type": "string",
845                        "description": "The key to delete"
846                    }
847                },
848                "required": ["key"]
849            }),
850        }
851    }
852
853    async fn execute(&self, arguments: Value) -> Result<String> {
854        let key = arguments["key"]
855            .as_str()
856            .ok_or_else(|| Error::BadToolArgs {
857                name: "scratchpad_delete".into(),
858                message: "missing required parameter: key".to_string(),
859            })?
860            .to_string();
861
862        let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
863        let path = scratchpad_path(&self.workspace);
864        let mut pad = Scratchpad::load(&path)?;
865        if pad.delete(&key) {
866            pad.save(&path)?;
867            Ok(format!("scratchpad key '{key}' deleted"))
868        } else {
869            Ok(format!("no such key: {key}"))
870        }
871    }
872}
873
874pub struct ScratchpadList {
875    workspace: PathBuf,
876}
877
878impl ScratchpadList {
879    pub fn new(workspace: impl Into<PathBuf>) -> Self {
880        Self {
881            workspace: workspace.into(),
882        }
883    }
884}
885
886#[async_trait]
887impl Tool for ScratchpadList {
888    fn is_deferred(&self) -> bool {
889        true
890    }
891
892    fn spec(&self) -> ToolSpec {
893        ToolSpec {
894            name: "scratchpad_list".into(),
895            description: "List all keys currently stored in working memory (scratchpad).".into(),
896            parameters: json!({
897                "type": "object",
898                "properties": {}
899            }),
900        }
901    }
902
903    async fn execute(&self, _arguments: Value) -> Result<String> {
904        let path = scratchpad_path(&self.workspace);
905        let pad = Scratchpad::load(&path)?;
906        let keys = pad.keys();
907        if keys.is_empty() {
908            return Ok("scratchpad is empty".to_string());
909        }
910        Ok(keys.join("\n"))
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917
918    // ── MemoryStore helpers ──────────────────────────────────────────────────
919
920    fn fresh_store() -> MemoryStore {
921        MemoryStore::default()
922    }
923
924    // --- load ---
925
926    #[test]
927    fn load_returns_empty_store_when_file_missing() {
928        let store = MemoryStore::load(std::path::Path::new("/nonexistent/path/memory.json"))
929            .expect("missing file should return empty store, not an error");
930        assert!(store.notes.is_empty());
931    }
932
933    #[test]
934    fn load_parses_existing_file() {
935        let tmp = tempfile::NamedTempFile::new().unwrap();
936        let json =
937            r#"{"notes":[{"id":"N1","tags":["rust"],"text":"hello","ts":"2026-01-01T00:00:00Z"}]}"#;
938        std::fs::write(tmp.path(), json).unwrap();
939        let store = MemoryStore::load(tmp.path()).expect("valid file must load");
940        assert_eq!(store.notes.len(), 1);
941        assert_eq!(store.notes[0].id, "N1");
942        assert_eq!(store.notes[0].text, "hello");
943    }
944
945    // --- save + load round-trip ---
946
947    #[test]
948    fn save_and_reload_round_trip() {
949        let tmp = tempfile::NamedTempFile::new().unwrap();
950        let mut store = fresh_store();
951        store.add("round-trip text".into(), vec!["rt".into()]);
952        store.save(tmp.path()).expect("save must succeed");
953
954        let loaded = MemoryStore::load(tmp.path()).expect("reload must succeed");
955        assert_eq!(loaded.notes.len(), 1);
956        assert_eq!(loaded.notes[0].text, "round-trip text");
957    }
958
959    // --- next_id ---
960
961    #[test]
962    fn next_id_on_empty_store_is_n1() {
963        assert_eq!(fresh_store().next_id(), "N1");
964    }
965
966    #[test]
967    fn next_id_increments_past_existing_notes() {
968        let mut store = fresh_store();
969        store.add("first".into(), vec![]);
970        store.add("second".into(), vec![]);
971        assert_eq!(store.next_id(), "N3");
972    }
973
974    // --- add ---
975
976    #[test]
977    fn add_returns_the_assigned_id() {
978        let mut store = fresh_store();
979        let id = store.add("my note".into(), vec!["tag1".into()]);
980        assert_eq!(id, "N1", "first add must return N1");
981        assert_eq!(store.notes[0].id, "N1");
982        assert_eq!(store.notes[0].text, "my note");
983    }
984
985    #[test]
986    fn add_second_note_gets_n2() {
987        let mut store = fresh_store();
988        store.add("a".into(), vec![]);
989        let id2 = store.add("b".into(), vec![]);
990        assert_eq!(id2, "N2");
991        assert_eq!(store.notes.len(), 2);
992    }
993
994    // --- remove ---
995
996    #[test]
997    fn remove_existing_note_returns_true() {
998        let mut store = fresh_store();
999        let id = store.add("to remove".into(), vec![]);
1000        assert!(store.remove(&id), "remove existing must return true");
1001        assert!(store.notes.is_empty(), "note must actually be gone");
1002    }
1003
1004    #[test]
1005    fn remove_absent_note_returns_false() {
1006        let mut store = fresh_store();
1007        assert!(!store.remove("N999"), "remove absent must return false");
1008    }
1009
1010    #[test]
1011    fn remove_only_removes_target_note() {
1012        let mut store = fresh_store();
1013        let id1 = store.add("keep".into(), vec![]);
1014        let id2 = store.add("remove me".into(), vec![]);
1015        assert!(store.remove(&id2));
1016        assert_eq!(store.notes.len(), 1);
1017        assert_eq!(store.notes[0].id, id1);
1018    }
1019
1020    // --- search ---
1021
1022    #[test]
1023    fn search_finds_by_text_substring() {
1024        let mut store = fresh_store();
1025        store.add("hello world".into(), vec![]);
1026        store.add("goodbye".into(), vec![]);
1027        let results = store.search(Some("hello"), None, 10);
1028        assert_eq!(results.len(), 1);
1029        assert!(results[0].text.contains("hello"));
1030    }
1031
1032    #[test]
1033    fn search_finds_by_tag_in_text_or_tag_field() {
1034        let mut store = fresh_store();
1035        // Note with keyword only in the tags field (not text)
1036        store.add("general note".into(), vec!["special-tag".into()]);
1037        // Search by query that matches the tag string
1038        let results = store.search(Some("special-tag"), None, 10);
1039        assert_eq!(
1040            results.len(),
1041            1,
1042            "search must find query match in tags via || branch"
1043        );
1044    }
1045
1046    #[test]
1047    fn search_exact_tag_filter() {
1048        let mut store = fresh_store();
1049        store.add("rust note".into(), vec!["rust".into()]);
1050        store.add("go note".into(), vec!["go".into()]);
1051        let results = store.search(None, Some("rust"), 10);
1052        assert_eq!(results.len(), 1);
1053        assert_eq!(results[0].tags[0], "rust");
1054    }
1055
1056    #[test]
1057    fn search_limit_applies() {
1058        let mut store = fresh_store();
1059        for i in 0..5 {
1060            store.add(format!("note {i}"), vec![]);
1061        }
1062        let results = store.search(None, None, 3);
1063        assert_eq!(results.len(), 3);
1064    }
1065
1066    #[test]
1067    fn search_no_match_returns_empty() {
1068        let mut store = fresh_store();
1069        store.add("hello".into(), vec![]);
1070        let results = store.search(Some("zzz_no_match"), None, 10);
1071        assert!(results.is_empty());
1072    }
1073
1074    // --- chrono_now_rfc3339 ---
1075
1076    #[test]
1077    fn chrono_now_rfc3339_is_nonempty_and_not_placeholder() {
1078        let ts = chrono_now_rfc3339();
1079        assert!(!ts.is_empty(), "timestamp must not be empty");
1080        assert_ne!(ts, "xyzzy", "timestamp must not be placeholder");
1081        // Must match RFC 3339 pattern: YYYY-MM-DDTHH:MM:SSZ
1082        assert!(
1083            ts.len() >= 20 && ts.ends_with('Z'),
1084            "timestamp must end with Z; got: {ts}"
1085        );
1086        assert!(
1087            ts.contains('T'),
1088            "timestamp must contain T separator; got: {ts}"
1089        );
1090    }
1091
1092    #[test]
1093    fn chrono_now_rfc3339_year_is_plausible() {
1094        let ts = chrono_now_rfc3339();
1095        let year: u32 = ts[..4].parse().expect("first 4 chars must be a year");
1096        assert!(year >= 2024, "year must be >= 2024; got {year}");
1097    }
1098
1099    // --- days_to_date ---
1100
1101    #[test]
1102    fn days_to_date_day_zero_is_unix_epoch() {
1103        assert_eq!(days_to_date(0), (1970, 1, 1));
1104    }
1105
1106    #[test]
1107    fn days_to_date_day_365_is_1971_01_01() {
1108        assert_eq!(days_to_date(365), (1971, 1, 1));
1109    }
1110
1111    #[test]
1112    fn days_to_date_leap_year_day_60_is_feb_29() {
1113        // 1972 is a leap year. Day 365 (1971-01-01) + 365 (1971) = 730 → 1972-01-01.
1114        // Day 730 + 31 (Jan) + 29 (Feb leap) - 1 = 759 → 1972-02-29
1115        assert_eq!(days_to_date(730 + 59), (1972, 2, 29));
1116    }
1117
1118    #[test]
1119    fn days_to_date_known_date_2026_07_06() {
1120        // 2026-07-06T00:00:00Z is exactly 20640 days since Unix epoch.
1121        let (y, m, d) = days_to_date(20640);
1122        assert_eq!(y, 2026);
1123        assert_eq!(m, 7);
1124        assert_eq!(d, 6);
1125    }
1126
1127    // --- is_leap ---
1128
1129    #[test]
1130    fn is_leap_regular_leap_year() {
1131        // divisible by 4, not 100 → leap
1132        assert!(is_leap(1972));
1133        assert!(is_leap(2024));
1134    }
1135
1136    #[test]
1137    fn is_leap_century_non_leap() {
1138        // divisible by 100 but not 400 → NOT leap
1139        assert!(!is_leap(1900));
1140        assert!(!is_leap(1800));
1141    }
1142
1143    #[test]
1144    fn is_leap_400_year_is_leap() {
1145        // divisible by 400 → leap
1146        assert!(is_leap(2000));
1147        assert!(is_leap(1600));
1148    }
1149
1150    #[test]
1151    fn is_leap_ordinary_non_leap() {
1152        // not divisible by 4 → NOT leap
1153        assert!(!is_leap(1971));
1154        assert!(!is_leap(2023));
1155    }
1156
1157    // --- chrono_now_rfc3339 arithmetic ---
1158
1159    #[test]
1160    fn chrono_now_rfc3339_minutes_and_seconds_in_range() {
1161        let ts = chrono_now_rfc3339();
1162        // Format: YYYY-MM-DDTHH:MM:SSZ  (positions 14-15 = minutes, 17-18 = seconds)
1163        let mm: u32 = ts[14..16].parse().expect("minutes must parse");
1164        let ss: u32 = ts[17..19].parse().expect("seconds must parse");
1165        assert!(mm < 60, "minutes out of range: {mm}");
1166        assert!(ss < 60, "seconds out of range: {ss}");
1167    }
1168
1169    // ── Scratchpad unit tests ────────────────────────────────────────────────
1170
1171    fn fresh_scratchpad() -> Scratchpad {
1172        Scratchpad::default()
1173    }
1174
1175    #[test]
1176    fn scratchpad_set_inserts_new_entry() {
1177        // kills function-level replacement of Scratchpad::set
1178        let mut pad = fresh_scratchpad();
1179        pad.set("k1".into(), "v1".into());
1180        assert_eq!(pad.get("k1"), Some("v1"));
1181    }
1182
1183    #[test]
1184    fn scratchpad_set_updates_existing_entry() {
1185        // kills the `if let Some(existing)` branch: without update, old value persists
1186        let mut pad = fresh_scratchpad();
1187        pad.set("key".into(), "first".into());
1188        pad.set("key".into(), "second".into());
1189        assert_eq!(
1190            pad.get("key"),
1191            Some("second"),
1192            "set must update existing key"
1193        );
1194        assert_eq!(
1195            pad.entries.len(),
1196            1,
1197            "update must not add a duplicate entry"
1198        );
1199    }
1200
1201    #[test]
1202    fn scratchpad_get_returns_none_for_missing_key() {
1203        // kills `_ => Some(...)` mutations in get
1204        let pad = fresh_scratchpad();
1205        assert!(pad.get("nonexistent").is_none());
1206    }
1207
1208    #[test]
1209    fn scratchpad_delete_returns_true_for_existing_key() {
1210        // kills `entries.len() < before` mutations and function-level replacement
1211        let mut pad = fresh_scratchpad();
1212        pad.set("k".into(), "v".into());
1213        assert!(pad.delete("k"), "delete must return true when key existed");
1214        assert!(pad.get("k").is_none(), "key must be gone after delete");
1215    }
1216
1217    #[test]
1218    fn scratchpad_delete_returns_false_for_missing_key() {
1219        // kills `!= k` → `== k` retain mutation
1220        let mut pad = fresh_scratchpad();
1221        assert!(
1222            !pad.delete("ghost"),
1223            "delete must return false for absent key"
1224        );
1225    }
1226
1227    #[test]
1228    fn scratchpad_keys_returns_all_keys_in_order() {
1229        // kills function-level replacement of Scratchpad::keys
1230        let mut pad = fresh_scratchpad();
1231        pad.set("alpha".into(), "1".into());
1232        pad.set("beta".into(), "2".into());
1233        let keys = pad.keys();
1234        assert_eq!(keys, vec!["alpha", "beta"]);
1235    }
1236
1237    #[test]
1238    fn scratchpad_summary_returns_empty_for_no_entries() {
1239        // kills `if pad.entries.is_empty()` guard removal
1240        let tmp = crate::test_util::IsolatedWorkspace::new();
1241        let summary = scratchpad_summary(tmp.path());
1242        assert!(
1243            summary.is_empty(),
1244            "empty scratchpad must produce empty summary, got: {summary}"
1245        );
1246    }
1247
1248    #[test]
1249    fn scratchpad_summary_truncates_long_values() {
1250        // kills `if entry.value.len() > 200` guard removal / off-by-one mutations
1251        let tmp = crate::test_util::IsolatedWorkspace::new();
1252        let path = scratchpad_path(tmp.path());
1253        let mut pad = Scratchpad::default();
1254        pad.set("long_key".into(), "X".repeat(300));
1255        pad.save(&path).unwrap();
1256
1257        let summary = scratchpad_summary(tmp.path());
1258        assert!(
1259            !summary.contains(&"X".repeat(300)),
1260            "value > 200 chars must be truncated in summary"
1261        );
1262        assert!(
1263            summary.contains("..."),
1264            "truncated value must end with '...': {summary}"
1265        );
1266    }
1267
1268    // ── memory_summary unit tests ───────────────────────────────────────────────
1269
1270    #[test]
1271    fn memory_summary_returns_empty_for_no_notes() {
1272        let tmp = crate::test_util::IsolatedWorkspace::new();
1273        let summary = memory_summary(tmp.path(), 10);
1274        assert!(
1275            summary.is_empty(),
1276            "empty memory must produce empty summary, got: {summary}"
1277        );
1278    }
1279
1280    #[test]
1281    fn memory_summary_uses_index_format() {
1282        let tmp = crate::test_util::IsolatedWorkspace::new();
1283        let path = memory_path(tmp.path());
1284        let mut store = MemoryStore::default();
1285        store.add("test note content".into(), vec!["tag1".into()]);
1286        store.save(&path).unwrap();
1287
1288        let summary = memory_summary(tmp.path(), 10);
1289        assert!(
1290            summary.contains("# Memory Index"),
1291            "summary must start with '# Memory Index'"
1292        );
1293        assert!(
1294            summary.contains("- [N1](recall) — test note content"),
1295            "summary must use index format: '- [ID](recall) — hook'"
1296        );
1297        assert!(
1298            !summary.contains("[tag1]"),
1299            "index format should not inline tags (they're in the full note)"
1300        );
1301    }
1302
1303    #[test]
1304    fn memory_summary_truncates_long_hooks() {
1305        let tmp = crate::test_util::IsolatedWorkspace::new();
1306        let path = memory_path(tmp.path());
1307        let mut store = MemoryStore::default();
1308        store.add("X".repeat(200), vec![]);
1309        store.save(&path).unwrap();
1310
1311        let summary = memory_summary(tmp.path(), 10);
1312        assert!(
1313            summary.contains("..."),
1314            "long hook must be truncated with '...'"
1315        );
1316        // Verify the index format is preserved even with truncation
1317        assert!(
1318            summary.contains("- [N1](recall) — "),
1319            "truncated hook must still use index format"
1320        );
1321    }
1322
1323    #[test]
1324    fn memory_summary_replaces_newlines_in_hook() {
1325        let tmp = crate::test_util::IsolatedWorkspace::new();
1326        let path = memory_path(tmp.path());
1327        let mut store = MemoryStore::default();
1328        store.add("line1\nline2\nline3".into(), vec![]);
1329        store.save(&path).unwrap();
1330
1331        let summary = memory_summary(tmp.path(), 10);
1332        // Find the index line (starts with "- [N")
1333        let index_line = summary
1334            .lines()
1335            .find(|l| l.starts_with("- [N"))
1336            .expect("should find an index line");
1337        assert!(
1338            !index_line.contains('\n'),
1339            "newlines in hook must be replaced with spaces to keep index compact"
1340        );
1341        assert!(
1342            index_line.contains("line1 line2 line3") || index_line.contains("line1 line2"),
1343            "hook content should be preserved with spaces instead of newlines"
1344        );
1345    }
1346
1347    #[test]
1348    fn memory_summary_respects_limit() {
1349        let tmp = crate::test_util::IsolatedWorkspace::new();
1350        let path = memory_path(tmp.path());
1351        let mut store = MemoryStore::default();
1352        for i in 0..10 {
1353            store.add(format!("note {}", i), vec![]);
1354        }
1355        store.save(&path).unwrap();
1356
1357        let summary = memory_summary(tmp.path(), 3);
1358        // Should have header + 3 entries
1359        let entry_count = summary.matches("- [N").count();
1360        assert_eq!(entry_count, 3, "summary must respect limit parameter");
1361    }
1362
1363    #[test]
1364    fn memory_summary_most_recent_first() {
1365        let tmp = crate::test_util::IsolatedWorkspace::new();
1366        let path = memory_path(tmp.path());
1367        let mut store = MemoryStore::default();
1368        store.add("first".into(), vec![]);
1369        store.add("second".into(), vec![]);
1370        store.add("third".into(), vec![]);
1371        store.save(&path).unwrap();
1372
1373        let summary = memory_summary(tmp.path(), 10);
1374        let lines: Vec<&str> = summary.lines().collect();
1375        // Find the index lines (starting with "- [N")
1376        let mut index_lines = Vec::new();
1377        for line in lines {
1378            if line.starts_with("- [N") {
1379                index_lines.push(line);
1380            }
1381        }
1382        // Most recent (N3, N2, N1) should appear first
1383        assert!(
1384            index_lines[0].contains("N3"),
1385            "most recent note (N3) should appear first"
1386        );
1387        assert!(
1388            index_lines[2].contains("N1"),
1389            "oldest note (N1) should appear last"
1390        );
1391    }
1392}