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            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    /// Save to disk, creating parent directories if needed.
59    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    /// Generate the next monotonic ID.
78    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    /// Add a note, returning its ID.
90    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    /// Remove a note by ID. Returns true if found.
103    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    /// Search notes by query (case-insensitive substring in text or tags)
110    /// or by exact tag match. Returns up to `limit` results, most recent first.
111    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        // Most recent first (reverse chronological)
126        results.reverse();
127        results.truncate(limit);
128        results
129    }
130}
131
132/// Get an RFC 3339 timestamp string.
133fn chrono_now_rfc3339() -> String {
134    // Use std::time to build a simple UTC timestamp without chrono dependency.
135    let dur = std::time::SystemTime::now()
136        .duration_since(std::time::UNIX_EPOCH)
137        .unwrap_or_default();
138    let secs = dur.as_secs();
139    // Format as ISO 8601 / RFC 3339
140    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    // Compute year/month/day from days since epoch (simplified)
147    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
154/// Convert days since Unix epoch to (year, month, day).
155/// Uses a simple leap-year-aware algorithm.
156fn days_to_date(mut days: u64) -> (u64, u64, u64) {
157    // Start from 1970-01-01
158    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; // 1-indexed
181    (year, month, day)
182}
183
184fn is_leap(year: u64) -> bool {
185    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
186}
187
188/// Determine the memory file path based on workspace and env var.
189pub 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
198/// Load the memory store from the workspace-relative path.
199pub fn load_memory(workspace: &std::path::Path) -> Result<MemoryStore> {
200    let path = memory_path(workspace);
201    MemoryStore::load(&path)
202}
203
204/// Build a memory summary string for injection into the system prompt.
205/// Returns the top N most recent notes as a formatted block, or empty
206/// string if no notes exist.
207pub 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    // Most recent first
221    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        // Truncate long text for the summary
230        let text_preview = if note.text.len() > 120 {
231            format!("{}...", crate::truncate_str(&note.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
240// ---------------------------------------------------------------------------
241// Tool implementations
242// ---------------------------------------------------------------------------
243
244pub struct Remember {
245    workspace: PathBuf,
246    /// Mutex for thread-safe access to the memory file.
247    lock: Mutex<()>,
248    /// Optional vector store for semantic indexing.
249    vector_store: Arc<dyn VectorStore>,
250    /// Optional embedding provider for generating vectors.
251    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    /// Inject a vector store + embedding provider for semantic indexing.
265    ///
266    /// When set, every `remember` call will also embed the text and upsert the
267    /// vector into the store, enabling semantic `recall` queries.
268    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        // Also index in the vector store if one is configured.
333        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    /// Optional vector store for semantic retrieval.
352    vector_store: Arc<dyn VectorStore>,
353    /// Optional embedding provider for query vectorisation.
354    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    /// Inject a vector store + embedding provider for semantic retrieval.
367    ///
368    /// When set, `recall` will embed the query and perform cosine-similarity
369    /// search instead of keyword substring search.
370    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        // Try vector search first; fall back to file-based keyword search
418        // when the vector store or embedding provider is a no-op.
419        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            // Use the vector store (semantic or keyword fallback inside the store).
424            match self.vector_store.search(query_vec, query, limit).await {
425                Ok(entries) if !entries.is_empty() => {
426                    // Apply tag filter if requested.
427                    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        // File-based fallback (or when only tag filter is used).
455        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// ---------------------------------------------------------------------------
535// Scratchpad (working memory)
536// ---------------------------------------------------------------------------
537
538/// A single scratchpad entry.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct ScratchpadEntry {
541    pub key: String,
542    pub value: String,
543}
544
545/// The on-disk scratchpad store.
546#[derive(Debug, Clone, Serialize, Deserialize, Default)]
547pub struct Scratchpad {
548    pub entries: Vec<ScratchpadEntry>,
549}
550
551impl Scratchpad {
552    /// Load from a path, returning an empty scratchpad if the file doesn't exist.
553    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    /// Save to disk, creating parent directories if needed.
568    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    /// Set a key-value pair (insert or update).
587    fn set(&mut self, key: String, value: String) {
588        // Replace existing entry with the same key, or add new one.
589        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    /// Get the value for a key.
597    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    /// Delete an entry by key. Returns true if found.
605    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    /// List all keys.
612    fn keys(&self) -> Vec<&str> {
613        self.entries.iter().map(|e| e.key.as_str()).collect()
614    }
615}
616
617/// Determine the scratchpad file path. Lives under the per-user data
618/// dir so it doesn't pollute the project tree:
619/// `~/.recursive/workspaces/<ws-hash>/scratchpad.json`.
620pub 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
625/// Load the scratchpad from the workspace-relative path.
626pub fn load_scratchpad(workspace: &std::path::Path) -> Result<Scratchpad> {
627    let path = scratchpad_path(workspace);
628    Scratchpad::load(&path)
629}
630
631/// Build a scratchpad summary string for injection into the system prompt.
632/// Returns a formatted block of all key-value pairs, or empty string if
633/// the scratchpad is empty.
634pub 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        // Truncate long values for the summary
646        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
656/// Migrate old-format scratchpad data (if any) to the new format.
657/// Currently a no-op placeholder for future migration logic.
658pub fn migrate_scratchpad(_workspace: &std::path::Path) -> Result<()> {
659    // No old format to migrate from yet.
660    Ok(())
661}
662
663// ---------------------------------------------------------------------------
664// WorkingMemoryTool: exposes scratchpad operations as tools
665// ---------------------------------------------------------------------------
666
667pub 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
729/// Helper: dispatch scratchpad operations based on the tool name.
730/// This is used by the multi-tool approach where one struct handles
731/// multiple tool names.
732pub 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}