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
47pub(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}