Skip to main content

dejadb_store/
memory_tool.rs

1//! LR-13 — Anthropic memory-tool backend adapter.
2//!
3//! Implements the client-side operation contract of Anthropic's memory
4//! tool ("you implement the storage backend"): `view`, `create`,
5//! `str_replace`, `insert`, `delete`, `rename` over a `/memories`
6//! path space — but backed by grains instead of naive files, so every
7//! edit is a supersession (full history), reads are `entity_latest`
8//! point lookups, provenance links survive renames, and crypto-erasure
9//! applies to the agent's memory directory.
10//!
11//! Mapping: one file = one supersession chain of Fact grains with
12//! `subject = <path>`, `relation = "memory_file"`, `object = <content
13//! digest>` (kept short so the dictionary never stores file bodies),
14//! and the body in the grain's `context.content` field.
15
16use dejadb_core::error::{Hash, DejaDbError, Result};
17use dejadb_core::types::Fact;
18use serde_json::{json, Value};
19
20use crate::DejaDB;
21
22pub const MEMORY_FILE_RELATION: &str = "memory_file";
23const ROOT: &str = "/memories";
24
25pub struct MemoryTool<'a> {
26    m: &'a mut DejaDB,
27    ns: String,
28}
29
30impl<'a> MemoryTool<'a> {
31    pub fn new(m: &'a mut DejaDB, ns: &str) -> Self {
32        MemoryTool { m, ns: ns.to_string() }
33    }
34
35    /// Dispatch one memory-tool command object (the shape Claude sends):
36    /// `{"command": "view", "path": "/memories", ...}` → result text.
37    pub fn execute(&mut self, cmd: &Value) -> Result<String> {
38        let command = cmd.get("command").and_then(|v| v.as_str()).unwrap_or("");
39        let path = cmd.get("path").and_then(|v| v.as_str()).unwrap_or("");
40        match command {
41            "view" => self.view(path),
42            "create" => self.create(
43                path,
44                cmd.get("file_text").and_then(|v| v.as_str()).unwrap_or(""),
45            ),
46            "str_replace" => self.str_replace(
47                path,
48                cmd.get("old_str").and_then(|v| v.as_str()).unwrap_or(""),
49                cmd.get("new_str").and_then(|v| v.as_str()).unwrap_or(""),
50            ),
51            "insert" => self.insert(
52                path,
53                cmd.get("insert_line").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
54                cmd.get("insert_text").and_then(|v| v.as_str()).unwrap_or(""),
55            ),
56            "delete" => self.delete(path),
57            "rename" => self.rename(
58                cmd.get("old_path").and_then(|v| v.as_str()).unwrap_or(path),
59                cmd.get("new_path").and_then(|v| v.as_str()).unwrap_or(""),
60            ),
61            other => Err(DejaDbError::Validation(format!(
62                "unknown memory command: {other}"
63            ))),
64        }
65    }
66
67    fn check_path(path: &str) -> Result<()> {
68        if !path.starts_with(ROOT) || path.contains("..") || path.contains("//") {
69            return Err(DejaDbError::Validation(format!(
70                "path must live under {ROOT} (got '{path}')"
71            )));
72        }
73        Ok(())
74    }
75
76    fn head(&mut self, path: &str) -> Result<Option<(Hash, String)>> {
77        match self.m.latest(&self.ns, path, MEMORY_FILE_RELATION)? {
78            Some(g) => {
79                let content = g
80                    .fields
81                    .get("context")
82                    .and_then(|c| c.get("content"))
83                    .and_then(|c| c.as_str())
84                    .unwrap_or("")
85                    .to_string();
86                Ok(Some((g.hash, content)))
87            }
88            None => Ok(None),
89        }
90    }
91
92    fn write_version(&mut self, path: &str, content: &str, prev: Option<Hash>) -> Result<Hash> {
93        use sha2::{Digest, Sha256};
94        let digest = hex::encode(&Sha256::digest(content.as_bytes())[..6]);
95        let mut f = Fact::new(path, MEMORY_FILE_RELATION, &format!("v:{digest}"));
96        f.common.namespace = Some(self.ns.clone());
97        f.common.context = Some(json!({ "content": content }));
98        // Index the body, not just "path memory_file v:digest" — otherwise
99        // memory files are invisible to BM25/vector recall.
100        f.common.embedding_text = Some(crate::migrate::clip_et(content));
101        f.common.source_type = Some("agent".to_string());
102        match prev {
103            Some(old) => self.m.supersede(&old, &mut f),
104            None => self.m.add(&f),
105        }
106    }
107
108    fn view(&mut self, path: &str) -> Result<String> {
109        Self::check_path(path)?;
110        let dirish = path == ROOT || path.ends_with('/');
111        if dirish {
112            let prefix = if path.ends_with('/') { path.to_string() } else { format!("{path}/") };
113            let files = self.m.subjects_with_relation(&self.ns, MEMORY_FILE_RELATION)?;
114            let listing: Vec<String> = files
115                .into_iter()
116                .filter(|f| f.starts_with(&prefix))
117                .collect();
118            if listing.is_empty() {
119                return Ok(format!("Directory: {path}\n(empty)"));
120            }
121            return Ok(format!("Directory: {path}\n{}", listing.join("\n")));
122        }
123        match self.head(path)? {
124            Some((_, content)) => {
125                let numbered: Vec<String> = content
126                    .lines()
127                    .enumerate()
128                    .map(|(i, l)| format!("{:>4}: {l}", i + 1))
129                    .collect();
130                Ok(numbered.join("\n"))
131            }
132            None => Err(DejaDbError::Validation(format!("file not found: {path}"))),
133        }
134    }
135
136    fn create(&mut self, path: &str, file_text: &str) -> Result<String> {
137        Self::check_path(path)?;
138        if path == ROOT || path.ends_with('/') {
139            return Err(DejaDbError::Validation("create requires a file path".into()));
140        }
141        let prev = self.head(path)?.map(|(h, _)| h);
142        let existed = prev.is_some();
143        self.write_version(path, file_text, prev)?;
144        Ok(format!(
145            "{} {path}",
146            if existed { "Overwrote (new version of)" } else { "Created" }
147        ))
148    }
149
150    fn str_replace(&mut self, path: &str, old_str: &str, new_str: &str) -> Result<String> {
151        Self::check_path(path)?;
152        let (head, content) = self
153            .head(path)?
154            .ok_or_else(|| DejaDbError::Validation(format!("file not found: {path}")))?;
155        let occurrences = content.matches(old_str).count();
156        if old_str.is_empty() || occurrences == 0 {
157            return Err(DejaDbError::Validation("old_str not found in file".into()));
158        }
159        if occurrences > 1 {
160            return Err(DejaDbError::Validation(format!(
161                "old_str appears {occurrences} times — must be unique"
162            )));
163        }
164        let updated = content.replacen(old_str, new_str, 1);
165        self.write_version(path, &updated, Some(head))?;
166        Ok(format!("Replaced text in {path}"))
167    }
168
169    fn insert(&mut self, path: &str, insert_line: usize, insert_text: &str) -> Result<String> {
170        Self::check_path(path)?;
171        let (head, content) = self
172            .head(path)?
173            .ok_or_else(|| DejaDbError::Validation(format!("file not found: {path}")))?;
174        let mut lines: Vec<&str> = content.lines().collect();
175        let at = insert_line.min(lines.len());
176        lines.insert(at, insert_text);
177        let updated = lines.join("\n");
178        self.write_version(path, &updated, Some(head))?;
179        Ok(format!("Inserted at line {at} in {path}"))
180    }
181
182    fn delete(&mut self, path: &str) -> Result<String> {
183        Self::check_path(path)?;
184        if path == ROOT {
185            return Err(DejaDbError::Validation("refusing to delete the root".into()));
186        }
187        // Forget the whole chain (host-level erasure; tombstoned in op-log).
188        let versions = self.m.history(&self.ns, path, MEMORY_FILE_RELATION)?;
189        if versions.is_empty() {
190            return Err(DejaDbError::Validation(format!("file not found: {path}")));
191        }
192        let n = versions.len();
193        for v in versions {
194            self.m.forget(&v.hash)?;
195        }
196        Ok(format!("Deleted {path} ({n} versions erased)"))
197    }
198
199    fn rename(&mut self, old_path: &str, new_path: &str) -> Result<String> {
200        Self::check_path(old_path)?;
201        Self::check_path(new_path)?;
202        let (old_head, content) = self
203            .head(old_path)?
204            .ok_or_else(|| DejaDbError::Validation(format!("file not found: {old_path}")))?;
205        if self.head(new_path)?.is_some() {
206            return Err(DejaDbError::Validation(format!("target exists: {new_path}")));
207        }
208        // New chain at the new path with provenance to the old head; then
209        // erase the old chain. (v1: history does not carry across renames —
210        // the provenance link preserves the connection.)
211        let mut f = {
212            use sha2::{Digest, Sha256};
213            let digest = hex::encode(&Sha256::digest(content.as_bytes())[..6]);
214            let mut f = Fact::new(new_path, MEMORY_FILE_RELATION, &format!("v:{digest}"));
215            f.common.namespace = Some(self.ns.clone());
216            f.common.context = Some(json!({ "content": content }));
217            f.common.embedding_text = Some(crate::migrate::clip_et(&content));
218            f.common.derived_from = Some(old_head.to_hex());
219            f
220        };
221        self.m.add(&f)?;
222        let versions = self.m.history(&self.ns, old_path, MEMORY_FILE_RELATION)?;
223        for v in versions {
224            self.m.forget(&v.hash)?;
225        }
226        let _ = &mut f;
227        Ok(format!("Renamed {old_path} → {new_path}"))
228    }
229}