Skip to main content

vtcode_core/tools/
native_memory.rs

1use anyhow::{Context, Result, anyhow, bail};
2use serde::Deserialize;
3use serde_json::{Value, json};
4use std::path::{Component, Path, PathBuf};
5
6use crate::config::PersistentMemoryConfig;
7use crate::config::loader::VTCodeConfig;
8use crate::persistent_memory::{
9    rebuild_generated_memory_files, resolve_persistent_memory_dir, scaffold_persistent_memory,
10};
11use crate::tools::error_helpers::deserialize_tool_args;
12
13const MEMORIES_ROOT: &str = "/memories";
14
15#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum NativeMemoryCommand {
18    View,
19    Create,
20    StrReplace,
21    Insert,
22    Delete,
23    Rename,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub struct NativeMemoryRequest {
28    pub command: NativeMemoryCommand,
29    #[serde(default)]
30    pub path: Option<String>,
31    #[serde(default)]
32    pub old_path: Option<String>,
33    #[serde(default)]
34    pub new_path: Option<String>,
35    #[serde(default)]
36    pub file_text: Option<String>,
37    #[serde(default)]
38    pub old_str: Option<String>,
39    #[serde(default)]
40    pub new_str: Option<String>,
41    #[serde(default)]
42    pub insert_line: Option<usize>,
43    #[serde(default)]
44    pub insert_text: Option<String>,
45}
46
47/// LLM-visible description for the `memory` tool, shared by the builtin
48/// registration and the tool pack. It must name the operation field the way
49/// [`parameter_schema`] does (`command`), since argument validation rejects
50/// unknown properties.
51pub(crate) const MEMORY_TOOL_DESCRIPTION: &str = "Access VT Code persistent memory files under /memories. Use command=view (path defaults to /memories) to list available notes before reading or updating; writes are limited to preferences.md, repository-facts.md, and notes/**. Returns file listing or file content.";
52
53pub fn parameter_schema() -> Value {
54    json!({
55        "type": "object",
56        "properties": {
57            "command": {
58                "type": "string",
59                "enum": ["view", "create", "str_replace", "insert", "delete", "rename"],
60                "description": "Memory operation to perform."
61            },
62            "path": {
63                "type": "string",
64                "description": "Path under /memories for view/create/str_replace/insert/delete."
65            },
66            "old_path": {
67                "type": "string",
68                "description": "Existing path under /memories for rename."
69            },
70            "new_path": {
71                "type": "string",
72                "description": "Destination path under /memories for rename."
73            },
74            "file_text": {
75                "type": "string",
76                "description": "Full file contents for create."
77            },
78            "old_str": {
79                "type": "string",
80                "description": "Exact substring to replace for str_replace."
81            },
82            "new_str": {
83                "type": "string",
84                "description": "Replacement string for str_replace."
85            },
86            "insert_line": {
87                "type": "integer",
88                "minimum": 0,
89                "description": "Zero-based line index for insert."
90            },
91            "insert_text": {
92                "type": "string",
93                "description": "Text to insert at insert_line."
94            }
95        },
96        "required": ["command"],
97        "additionalProperties": false
98    })
99}
100
101pub async fn execute(workspace_root: &Path, config: &PersistentMemoryConfig, args: Value) -> Result<Value> {
102    let request: NativeMemoryRequest = deserialize_tool_args(&args, "memory")?;
103    let root = prepare_root(workspace_root, config).await?;
104    let output = execute_request(&root, workspace_root, config, request).await?;
105    Ok(Value::String(output))
106}
107
108pub async fn execute_with_vt_config(workspace_root: &Path, vt_cfg: &VTCodeConfig, args: Value) -> Result<Value> {
109    execute_with_persistent_memory_config(
110        workspace_root,
111        &vt_cfg.agent.persistent_memory,
112        vt_cfg.persistent_memory_enabled(),
113        args,
114    )
115    .await
116}
117
118pub(crate) async fn execute_with_persistent_memory_config(
119    workspace_root: &Path,
120    config: &PersistentMemoryConfig,
121    enabled: bool,
122    args: Value,
123) -> Result<Value> {
124    if !enabled {
125        bail!(
126            "Persistent memory is disabled. Enable features.memories and agent.persistent_memory.enabled to use /memories"
127        );
128    }
129
130    execute(workspace_root, config, args).await
131}
132
133async fn prepare_root(workspace_root: &Path, config: &PersistentMemoryConfig) -> Result<PathBuf> {
134    scaffold_persistent_memory(config, workspace_root)
135        .await
136        .context("Failed to scaffold persistent memory layout")?;
137    let cfg = config.clone();
138    let ws = workspace_root.to_path_buf();
139    tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
140        .await
141        .context("Persistent memory directory resolution task panicked")??
142        .ok_or_else(|| anyhow!("Persistent memory directory could not be resolved"))
143}
144
145async fn execute_request(
146    root: &Path,
147    workspace_root: &Path,
148    config: &PersistentMemoryConfig,
149    request: NativeMemoryRequest,
150) -> Result<String> {
151    match request.command {
152        NativeMemoryCommand::View => {
153            let path = request.path.as_deref().unwrap_or(MEMORIES_ROOT);
154            view(root, path).await
155        }
156        NativeMemoryCommand::Create => {
157            let path = required_text(request.path.as_deref(), "path")?;
158            let file_text = required_text(request.file_text.as_deref(), "file_text")?;
159            let resolved = resolve_virtual_path(root, path)?;
160            ensure_writable_path(&resolved.relative, path)?;
161            if let Some(parent) = resolved.absolute.parent() {
162                tokio::fs::create_dir_all(parent)
163                    .await
164                    .with_context(|| format!("Failed to create {}", parent.display()))?;
165            }
166            tokio::fs::write(&resolved.absolute, file_text)
167                .await
168                .with_context(|| format!("Failed to write {path}"))?;
169            rebuild_generated_memory_files(config, workspace_root).await?;
170            Ok(format!("Created {path}"))
171        }
172        NativeMemoryCommand::StrReplace => {
173            let path = required_text(request.path.as_deref(), "path")?;
174            let old_str = required_text(request.old_str.as_deref(), "old_str")?;
175            let new_str = request.new_str.as_deref().unwrap_or_default();
176            if old_str.is_empty() {
177                bail!("old_str must not be empty");
178            }
179            let resolved = resolve_virtual_path(root, path)?;
180            ensure_writable_path(&resolved.relative, path)?;
181            let content = tokio::fs::read_to_string(&resolved.absolute)
182                .await
183                .with_context(|| format!("Failed to read {path}"))?;
184            let matches = content.matches(old_str).count();
185            if matches == 0 {
186                bail!("old_str not found in {path}");
187            }
188            if matches > 1 {
189                bail!("old_str appears {matches} times in {path}; be more specific");
190            }
191            tokio::fs::write(&resolved.absolute, content.replacen(old_str, new_str, 1))
192                .await
193                .with_context(|| format!("Failed to write {path}"))?;
194            rebuild_generated_memory_files(config, workspace_root).await?;
195            Ok(format!("Replaced in {path}"))
196        }
197        NativeMemoryCommand::Insert => {
198            let path = required_text(request.path.as_deref(), "path")?;
199            let insert_line = request.insert_line.ok_or_else(|| anyhow!("insert_line is required"))?;
200            let insert_text = required_text(request.insert_text.as_deref(), "insert_text")?;
201            let resolved = resolve_virtual_path(root, path)?;
202            ensure_writable_path(&resolved.relative, path)?;
203            let content = tokio::fs::read_to_string(&resolved.absolute)
204                .await
205                .with_context(|| format!("Failed to read {path}"))?;
206            let mut lines = content.split('\n').map(ToOwned::to_owned).collect::<Vec<_>>();
207            if insert_line > lines.len() {
208                bail!("insert_line {insert_line} is out of bounds for {path}");
209            }
210            lines.insert(insert_line, insert_text.to_string());
211            tokio::fs::write(&resolved.absolute, lines.join("\n"))
212                .await
213                .with_context(|| format!("Failed to write {path}"))?;
214            rebuild_generated_memory_files(config, workspace_root).await?;
215            Ok(format!("Inserted at line {insert_line} in {path}"))
216        }
217        NativeMemoryCommand::Delete => {
218            let path = required_text(request.path.as_deref(), "path")?;
219            let resolved = resolve_virtual_path(root, path)?;
220            ensure_writable_path(&resolved.relative, path)?;
221            let metadata = tokio::fs::metadata(&resolved.absolute)
222                .await
223                .with_context(|| format!("Failed to stat {path}"))?;
224            if metadata.is_dir() {
225                tokio::fs::remove_dir_all(&resolved.absolute)
226                    .await
227                    .with_context(|| format!("Failed to delete {path}"))?;
228            } else {
229                tokio::fs::remove_file(&resolved.absolute)
230                    .await
231                    .with_context(|| format!("Failed to delete {path}"))?;
232            }
233            rebuild_generated_memory_files(config, workspace_root).await?;
234            Ok(format!("Deleted {path}"))
235        }
236        NativeMemoryCommand::Rename => {
237            let old_path = required_text(request.old_path.as_deref(), "old_path")?;
238            let new_path = required_text(request.new_path.as_deref(), "new_path")?;
239            let old_resolved = resolve_virtual_path(root, old_path)?;
240            let new_resolved = resolve_virtual_path(root, new_path)?;
241            ensure_writable_path(&old_resolved.relative, old_path)?;
242            ensure_writable_path(&new_resolved.relative, new_path)?;
243            if let Some(parent) = new_resolved.absolute.parent() {
244                tokio::fs::create_dir_all(parent)
245                    .await
246                    .with_context(|| format!("Failed to create {}", parent.display()))?;
247            }
248            tokio::fs::rename(&old_resolved.absolute, &new_resolved.absolute)
249                .await
250                .with_context(|| format!("Failed to rename {old_path} to {new_path}"))?;
251            rebuild_generated_memory_files(config, workspace_root).await?;
252            Ok(format!("Renamed {old_path} -> {new_path}"))
253        }
254    }
255}
256
257async fn view(root: &Path, path: &str) -> Result<String> {
258    let resolved = resolve_virtual_path(root, path)?;
259    let metadata = match tokio::fs::metadata(&resolved.absolute).await {
260        Ok(metadata) => metadata,
261        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
262            if path == MEMORIES_ROOT {
263                return Ok("Directory /memories is empty.".to_string());
264            }
265            bail!("{path} does not exist");
266        }
267        Err(error) => return Err(error).with_context(|| format!("Failed to stat {path}")),
268    };
269    if metadata.is_dir() {
270        let mut entries = tokio::fs::read_dir(&resolved.absolute)
271            .await
272            .with_context(|| format!("Failed to list {path}"))?;
273        let mut names = Vec::new();
274        while let Some(entry) = entries.next_entry().await? {
275            let file_name = entry.file_name().to_string_lossy().to_string();
276            let suffix = if entry.file_type().await.map(|file_type| file_type.is_dir()).unwrap_or(false) {
277                "/"
278            } else {
279                ""
280            };
281            names.push(format!("{file_name}{suffix}"));
282        }
283        names.sort();
284        if names.is_empty() {
285            return Ok("(empty directory)".to_string());
286        }
287        return Ok(names.join("\n"));
288    }
289
290    let content = tokio::fs::read_to_string(&resolved.absolute)
291        .await
292        .with_context(|| format!("Failed to read {path}"))?;
293    Ok(content
294        .split('\n')
295        .enumerate()
296        .map(|(idx, line)| format!("{:4}\t{}", idx + 1, line))
297        .collect::<Vec<_>>()
298        .join("\n"))
299}
300
301fn required_text<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> {
302    value
303        .map(str::trim)
304        .filter(|value| !value.is_empty())
305        .ok_or_else(|| anyhow!("{field} is required"))
306}
307
308struct ResolvedMemoryPath {
309    absolute: PathBuf,
310    relative: PathBuf,
311}
312
313fn resolve_virtual_path(root: &Path, virtual_path: &str) -> Result<ResolvedMemoryPath> {
314    let trimmed = virtual_path.trim();
315    if !trimmed.starts_with(MEMORIES_ROOT) {
316        bail!("memory paths must stay under {MEMORIES_ROOT}");
317    }
318
319    let relative_raw = trimmed.strip_prefix(MEMORIES_ROOT).unwrap_or_default().trim_start_matches('/');
320    let relative = if relative_raw.is_empty() {
321        PathBuf::new()
322    } else {
323        sanitize_relative_path(relative_raw)?
324    };
325
326    Ok(ResolvedMemoryPath {
327        absolute: if relative.as_os_str().is_empty() {
328            root.to_path_buf()
329        } else {
330            root.join(&relative)
331        },
332        relative,
333    })
334}
335
336fn sanitize_relative_path(raw: &str) -> Result<PathBuf> {
337    let path = Path::new(raw);
338    let mut sanitized = PathBuf::new();
339    for component in path.components() {
340        match component {
341            Component::Normal(part) => sanitized.push(part),
342            Component::CurDir => {}
343            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
344                bail!("memory paths may not escape {MEMORIES_ROOT}");
345            }
346        }
347    }
348    Ok(sanitized)
349}
350
351fn ensure_writable_path(relative: &Path, original: &str) -> Result<()> {
352    if is_writable_relative_path(relative) {
353        return Ok(());
354    }
355
356    bail!(
357        "{original} is read-only; writable paths are /memories/preferences.md, /memories/repository-facts.md, and /memories/notes/**"
358    );
359}
360
361fn is_writable_relative_path(relative: &Path) -> bool {
362    let components = relative
363        .components()
364        .filter_map(|component| match component {
365            Component::Normal(part) => Some(part.to_string_lossy().to_string()),
366            _ => None,
367        })
368        .collect::<Vec<_>>();
369
370    matches!(
371        components.as_slice(),
372        [single] if single == "preferences.md" || single == "repository-facts.md"
373    ) || matches!(components.as_slice(), [first, ..] if first == "notes" && components.len() >= 2)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::{MEMORIES_ROOT, MEMORY_TOOL_DESCRIPTION, execute, parameter_schema};
379    use crate::config::PersistentMemoryConfig;
380    use crate::persistent_memory::{
381        MEMORY_FILENAME, MEMORY_SUMMARY_FILENAME, ROLLOUT_SUMMARIES_DIRNAME, resolve_persistent_memory_dir,
382    };
383    use serde_json::json;
384    use tempfile::tempdir;
385
386    #[test]
387    fn description_names_the_schema_command_field() {
388        assert!(MEMORY_TOOL_DESCRIPTION.contains("command=view"));
389        assert!(!MEMORY_TOOL_DESCRIPTION.contains("action="));
390        assert!(parameter_schema()["properties"]["command"].is_object());
391        assert!(parameter_schema()["properties"].get("action").is_none());
392    }
393
394    #[tokio::test]
395    async fn parameter_schema_lists_supported_commands() {
396        let schema = parameter_schema();
397        assert_eq!(
398            schema["properties"]["command"]["enum"],
399            json!(["view", "create", "str_replace", "insert", "delete", "rename"])
400        );
401    }
402
403    #[tokio::test]
404    async fn execute_supports_crud_and_rebuilds_generated_files() {
405        let workspace = tempdir().expect("workspace");
406        let config = PersistentMemoryConfig {
407            enabled: true,
408            directory_override: Some(workspace.path().join(".memory").display().to_string()),
409            ..PersistentMemoryConfig::default()
410        };
411
412        execute(
413            workspace.path(),
414            &config,
415            json!({
416                "command": "create",
417                "path": "/memories/notes/research.md",
418                "file_text": "# Notes\n\n- First finding"
419            }),
420        )
421        .await
422        .expect("create");
423
424        let view = execute(
425            workspace.path(),
426            &config,
427            json!({
428                "command": "view",
429                "path": "/memories/notes/research.md"
430            }),
431        )
432        .await
433        .expect("view");
434        assert!(view.as_str().expect("string").contains("First finding"));
435
436        execute(
437            workspace.path(),
438            &config,
439            json!({
440                "command": "str_replace",
441                "path": "/memories/notes/research.md",
442                "old_str": "First finding",
443                "new_str": "Updated finding"
444            }),
445        )
446        .await
447        .expect("replace");
448        execute(
449            workspace.path(),
450            &config,
451            json!({
452                "command": "insert",
453                "path": "/memories/notes/research.md",
454                "insert_line": 2,
455                "insert_text": "- Follow-up"
456            }),
457        )
458        .await
459        .expect("insert");
460        execute(
461            workspace.path(),
462            &config,
463            json!({
464                "command": "rename",
465                "old_path": "/memories/notes/research.md",
466                "new_path": "/memories/notes/archive/research.md"
467            }),
468        )
469        .await
470        .expect("rename");
471
472        let memory_dir = resolve_persistent_memory_dir(&config, workspace.path())
473            .expect("dir")
474            .expect("resolved");
475        let summary = std::fs::read_to_string(memory_dir.join(MEMORY_SUMMARY_FILENAME)).expect("summary");
476        assert!(summary.contains("Updated finding") || summary.contains("Follow-up"));
477
478        execute(
479            workspace.path(),
480            &config,
481            json!({
482                "command": "delete",
483                "path": "/memories/notes/archive/research.md"
484            }),
485        )
486        .await
487        .expect("delete");
488        let recreated_summary = std::fs::read_to_string(memory_dir.join(MEMORY_SUMMARY_FILENAME)).expect("summary");
489        assert!(!recreated_summary.contains("Updated finding"));
490        assert!(!recreated_summary.contains("Follow-up"));
491    }
492
493    #[tokio::test]
494    async fn execute_blocks_path_traversal_and_generated_file_writes() {
495        let workspace = tempdir().expect("workspace");
496        let config = PersistentMemoryConfig {
497            enabled: true,
498            directory_override: Some(workspace.path().join(".memory").display().to_string()),
499            ..PersistentMemoryConfig::default()
500        };
501
502        let traversal = execute(
503            workspace.path(),
504            &config,
505            json!({
506                "command": "create",
507                "path": "/memories/notes/../../escape.md",
508                "file_text": "bad"
509            }),
510        )
511        .await;
512        traversal.unwrap_err();
513
514        let readonly = execute(
515            workspace.path(),
516            &config,
517            json!({
518                "command": "create",
519                "path": format!("{MEMORIES_ROOT}/{MEMORY_FILENAME}"),
520                "file_text": "bad"
521            }),
522        )
523        .await;
524        readonly.unwrap_err();
525    }
526
527    #[tokio::test]
528    async fn execute_views_root_and_rollout_directories_read_only() {
529        let workspace = tempdir().expect("workspace");
530        let config = PersistentMemoryConfig {
531            enabled: true,
532            directory_override: Some(workspace.path().join(".memory").display().to_string()),
533            ..PersistentMemoryConfig::default()
534        };
535
536        let root_listing = execute(
537            workspace.path(),
538            &config,
539            json!({
540                "command": "view",
541                "path": MEMORIES_ROOT
542            }),
543        )
544        .await
545        .expect("view root");
546        let root_listing = root_listing.as_str().expect("string");
547        assert!(root_listing.contains("notes/"));
548        assert!(root_listing.contains(&format!("{ROLLOUT_SUMMARIES_DIRNAME}/")));
549
550        let rollout_write = execute(
551            workspace.path(),
552            &config,
553            json!({
554                "command": "create",
555                "path": format!("{MEMORIES_ROOT}/{ROLLOUT_SUMMARIES_DIRNAME}/bad.md"),
556                "file_text": "bad"
557            }),
558        )
559        .await;
560        rollout_write.unwrap_err();
561    }
562}