Skip to main content

caliban_tools_builtin/memory/
mod.rs

1//! Built-in tools for reading and writing per-project auto-memory.
2//!
3//! See `docs/superpowers/specs/2026-05-24-auto-memory-design.md` and
4//! `docs/adr/0035-auto-memory.md`. Both tools are sandboxed to the
5//! `auto_memory_dir` resolved at construction time — they never touch paths
6//! outside it.
7
8use std::sync::Arc;
9use std::sync::OnceLock;
10
11use async_trait::async_trait;
12use caliban_agent_core::{Tool, ToolContext, ToolError};
13use caliban_memory::{TopicDraft, TopicKind, TopicLoader};
14use caliban_provider::{ContentBlock, TextBlock};
15use serde::Deserialize;
16use serde_json::{Value, json};
17
18/// `ReadMemoryTopic` — read a topic file by slug. Sandboxed to the loader's
19/// memory directory.
20#[derive(Debug)]
21pub struct ReadMemoryTopicTool {
22    loader: Arc<TopicLoader>,
23    schema: OnceLock<Value>,
24}
25
26impl ReadMemoryTopicTool {
27    /// Construct a `ReadMemoryTopic` tool backed by the given loader.
28    #[must_use]
29    pub fn new(loader: Arc<TopicLoader>) -> Self {
30        Self {
31            loader,
32            schema: OnceLock::new(),
33        }
34    }
35}
36
37#[derive(Debug, Deserialize)]
38struct ReadInput {
39    name: String,
40}
41
42#[async_trait]
43impl Tool for ReadMemoryTopicTool {
44    fn name(&self) -> &'static str {
45        "ReadMemoryTopic"
46    }
47
48    fn description(&self) -> &'static str {
49        "Read one auto-memory topic file by slug. The slug is the value in the `MEMORY.md` index entry (without `.md`). Returns the topic's markdown body."
50    }
51
52    fn input_schema(&self) -> &Value {
53        self.schema.get_or_init(|| {
54            json!({
55                "type": "object",
56                "properties": {
57                    "name": {
58                        "type": "string",
59                        "description": "Topic slug (kebab-case, no path separators, no leading '.')."
60                    }
61                },
62                "required": ["name"]
63            })
64        })
65    }
66
67    async fn invoke(&self, input: Value, _cx: ToolContext) -> Result<Vec<ContentBlock>, ToolError> {
68        let parsed: ReadInput = crate::parse_input(input)?;
69        let topic = self.loader.read(&parsed.name).map_err(|e| match e {
70            caliban_memory::MemoryError::InvalidSlug { .. } => {
71                ToolError::invalid_input(e.to_string())
72            }
73            other => ToolError::execution(other),
74        })?;
75        let text = format!(
76            "→ Memory topic '{}' ({}): {}\n\n{}",
77            topic.name,
78            topic.kind.as_str(),
79            topic.description,
80            topic.body
81        );
82        Ok(vec![ContentBlock::Text(TextBlock {
83            text,
84            cache_control: None,
85        })])
86    }
87}
88
89/// `WriteMemoryTopic` — atomically write a topic file *and* update the
90/// `MEMORY.md` index entry for it.
91#[derive(Debug)]
92pub struct WriteMemoryTopicTool {
93    loader: Arc<TopicLoader>,
94    schema: OnceLock<Value>,
95}
96
97impl WriteMemoryTopicTool {
98    /// Construct a `WriteMemoryTopic` tool backed by the given loader.
99    #[must_use]
100    pub fn new(loader: Arc<TopicLoader>) -> Self {
101        Self {
102            loader,
103            schema: OnceLock::new(),
104        }
105    }
106}
107
108#[derive(Debug, Deserialize)]
109struct WriteInput {
110    name: String,
111    description: String,
112    #[serde(rename = "type")]
113    kind: String,
114    body: String,
115}
116
117#[async_trait]
118impl Tool for WriteMemoryTopicTool {
119    fn name(&self) -> &'static str {
120        "WriteMemoryTopic"
121    }
122
123    fn description(&self) -> &'static str {
124        "Write or update an auto-memory topic file. Atomic: writes the topic file AND updates the MEMORY.md index entry in a single call. `type` must be one of: user, feedback, project, reference."
125    }
126
127    fn input_schema(&self) -> &Value {
128        self.schema.get_or_init(|| {
129            json!({
130                "type": "object",
131                "properties": {
132                    "name": {
133                        "type": "string",
134                        "description": "Topic slug (kebab-case, no path separators, no leading '.')."
135                    },
136                    "description": {
137                        "type": "string",
138                        "description": "One-line summary (≤120 chars). Surfaces into the MEMORY.md index entry."
139                    },
140                    "type": {
141                        "type": "string",
142                        "enum": ["user", "feedback", "project", "reference"],
143                        "description": "Memory type. user=facts about the user, feedback=durable rules/preferences, project=durable project facts, reference=stable external IDs."
144                    },
145                    "body": {
146                        "type": "string",
147                        "description": "Markdown body. Use [[other-slug]] to cross-reference siblings (purely informational)."
148                    }
149                },
150                "required": ["name", "description", "type", "body"]
151            })
152        })
153    }
154
155    fn parallel_conflict_key(&self, input: &Value) -> Option<String> {
156        let name = input.get("name").and_then(Value::as_str)?;
157        let kind = input.get("type").and_then(Value::as_str)?;
158        Some(format!("memory:{kind}:{name}"))
159    }
160
161    async fn invoke(&self, input: Value, _cx: ToolContext) -> Result<Vec<ContentBlock>, ToolError> {
162        let parsed: WriteInput = crate::parse_input(input)?;
163        let kind = TopicKind::parse(&parsed.kind).ok_or_else(|| {
164            ToolError::invalid_input(format!(
165                "type must be one of user|feedback|project|reference (got '{}')",
166                parsed.kind
167            ))
168        })?;
169        let draft = TopicDraft {
170            name: parsed.name,
171            description: parsed.description,
172            kind,
173            body: parsed.body,
174        };
175        let path = self.loader.write(&draft).map_err(|e| match e {
176            caliban_memory::MemoryError::InvalidSlug { .. } => {
177                ToolError::invalid_input(e.to_string())
178            }
179            other => ToolError::execution(other),
180        })?;
181        Ok(vec![ContentBlock::Text(TextBlock {
182            text: format!(
183                "→ Wrote memory topic '{}' to {} and updated MEMORY.md index",
184                draft.name,
185                path.display(),
186            ),
187            cache_control: None,
188        })])
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use caliban_memory::TopicLoader;
196    use tempfile::TempDir;
197    use tokio_util::sync::CancellationToken;
198
199    fn ctx() -> ToolContext {
200        ToolContext {
201            tool_use_id: "t1".into(),
202            cancel: CancellationToken::new(),
203            hooks: None,
204            turn_index: 0,
205        }
206    }
207
208    fn loader(dir: &std::path::Path) -> Arc<TopicLoader> {
209        Arc::new(TopicLoader::new(dir.to_path_buf()))
210    }
211
212    #[tokio::test]
213    async fn read_returns_body_content() {
214        let tmp = TempDir::new().unwrap();
215        std::fs::write(
216            tmp.path().join("foo.md"),
217            "---\nname: foo\ndescription: \"d\"\nmetadata:\n  type: user\n---\n\nThe body text.\n",
218        )
219        .unwrap();
220        let tool = ReadMemoryTopicTool::new(loader(tmp.path()));
221        let out = tool.invoke(json!({"name": "foo"}), ctx()).await.unwrap();
222        let ContentBlock::Text(t) = &out[0] else {
223            panic!()
224        };
225        assert!(t.text.contains("The body text."));
226        assert!(t.text.contains("foo"));
227        assert!(t.text.contains("(user)"));
228    }
229
230    #[tokio::test]
231    async fn write_creates_file_and_updates_index() {
232        let tmp = TempDir::new().unwrap();
233        let tool = WriteMemoryTopicTool::new(loader(tmp.path()));
234        tool.invoke(
235            json!({
236                "name": "personal-email",
237                "description": "use personal email for ~/dev/personal/**",
238                "type": "feedback",
239                "body": "Use john.ford2002@gmail.com.\n"
240            }),
241            ctx(),
242        )
243        .await
244        .unwrap();
245        let topic_path = tmp.path().join("personal-email.md");
246        assert!(topic_path.exists());
247        // tmp file must not linger
248        assert!(!tmp.path().join("personal-email.md.tmp").exists());
249        let index = std::fs::read_to_string(tmp.path().join("MEMORY.md")).unwrap();
250        assert!(index.contains("[personal-email](personal-email.md)"));
251    }
252
253    #[tokio::test]
254    async fn write_rejects_invalid_type() {
255        let tmp = TempDir::new().unwrap();
256        let tool = WriteMemoryTopicTool::new(loader(tmp.path()));
257        let err = tool
258            .invoke(
259                json!({
260                    "name": "bad",
261                    "description": "d",
262                    "type": "junk",
263                    "body": "x"
264                }),
265                ctx(),
266            )
267            .await
268            .unwrap_err();
269        assert!(matches!(err, ToolError::InvalidInput(_)));
270    }
271
272    #[tokio::test]
273    async fn write_rejects_traversal_slug() {
274        let tmp = TempDir::new().unwrap();
275        let tool = WriteMemoryTopicTool::new(loader(tmp.path()));
276        let err = tool
277            .invoke(
278                json!({
279                    "name": "../escape",
280                    "description": "d",
281                    "type": "user",
282                    "body": "x"
283                }),
284                ctx(),
285            )
286            .await
287            .unwrap_err();
288        assert!(matches!(err, ToolError::InvalidInput(_)));
289    }
290}