diff --git a/src/config/memory.rs b/src/config/memory.rs index 0a30da9..0598cab 100644 --- a/src/config/memory.rs +++ b/src/config/memory.rs @@ -118,6 +118,14 @@ pub struct MemoryFrontmatter { pub description: Option, #[serde(default, rename = "type")] pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub superseded_by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires: Option, } #[derive(Debug, Clone)] @@ -545,6 +553,7 @@ mod tests { name: "test".into(), description: Some("a test".into()), kind: Some("user".into()), + ..Default::default() }, body: "Hello world\nmore text".into(), }; diff --git a/src/config/prompts.rs b/src/config/prompts.rs index 5beda1b..cd82f14 100644 --- a/src/config/prompts.rs +++ b/src/config/prompts.rs @@ -18,10 +18,16 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {" - `memory__read(name)`: Read a specific drill file's full content. - `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace'). The MEMORY.md index is appended automatically; do not also update the index by hand. + Optional `superseded_by` / `expires` (YYYY-MM-DD) mark a memory as stale for later cleanup. + - `memory__rename(name, new_name, scope)`: Rename a drill file. Its index entry and every + [[wikilink]] to it are rewritten automatically. + - `memory__delete(name, scope)`: Delete a drill file and its index entry. Reports any + [[wikilinks]] left dangling in other files. - `memory__edit_index(scope, content)`: Replace the entire MEMORY.md at the given scope. Use this to add always-on facts, reorganize, prune stale entries, or fix descriptions. - `memory__list()`: See all known drill files and their metadata. - - `memory__lint()`: Health-check memory for orphans, broken links, oversized files. + - `memory__lint()`: Health-check memory for orphans, broken links, oversized files, + stale (superseded/expired) files, and index descriptions that drifted from the files. RULES: - Every interaction has two outputs: your answer AND any memory updates the conversation warrants. @@ -29,7 +35,11 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {" - All MEMORY.md edits MUST go through `memory__edit_index`. NEVER use `fs_write`, `fs_patch`, or any other generic file tool on MEMORY.md — Coyote manages its location and a stray MEMORY.md outside the managed path is invisible to memory. - - All drill files MUST go through `memory__write`. The index updates itself. + - All drill files MUST go through `memory__write`. The index updates itself. Renames and + deletions MUST go through `memory__rename` / `memory__delete` so links stay intact. + - When a fact becomes outdated, update it in place, delete it, or mark the old file with + `superseded_by`/`expires` so `memory__lint` flags it later. Never leave contradictory + memories side by side. - Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug. - NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk. Use coyote's Vault for secrets. diff --git a/src/function/memory.rs b/src/function/memory.rs index 9d1c26e..65f0ac3 100644 --- a/src/function/memory.rs +++ b/src/function/memory.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::{env, fs}; use anyhow::{Context, Result, anyhow, bail}; +use chrono::Local; use indexmap::IndexMap; use serde_json::{Value, json}; @@ -97,6 +98,32 @@ pub fn memory_function_declarations() -> Vec { ..Default::default() }, ), + ( + "superseded_by".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "Optional `name:` slug of the memory that replaces this one. \ + `memory__lint` flags superseded files for cleanup. Omitting this \ + on overwrite clears any previous value." + .into(), + ), + ..Default::default() + }, + ), + ( + "expires".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "Optional ISO date (YYYY-MM-DD) after which this memory is stale. \ + `memory__lint` flags expired files. Omitting this on overwrite \ + clears any previous value." + .into(), + ), + ..Default::default() + }, + ), ])), required: Some(vec![ "name".to_string(), @@ -164,6 +191,90 @@ pub fn memory_function_declarations() -> Vec { }, agent: false, }, + FunctionDeclaration { + name: format!("{MEMORY_FUNCTION_PREFIX}rename"), + description: + "Rename a memory file. Its MEMORY.md index entry and every [[wikilink]] to it in \ + other memory files are rewritten automatically." + .to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([ + ( + "name".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some("Current `name:` slug of the memory file".into()), + ..Default::default() + }, + ), + ( + "new_name".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "New kebab-case slug for the file (no extension)".into(), + ), + ..Default::default() + }, + ), + ( + "scope".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "Scope of the file: 'global' (user-level) or 'workspace' (project-level)" + .into(), + ), + ..Default::default() + }, + ), + ])), + required: Some(vec![ + "name".to_string(), + "new_name".to_string(), + "scope".to_string(), + ]), + ..Default::default() + }, + agent: false, + }, + FunctionDeclaration { + name: format!("{MEMORY_FUNCTION_PREFIX}delete"), + description: + "Delete a memory file and remove its MEMORY.md index entry. Reports any \ + [[wikilinks]] in other memory files left dangling by the deletion." + .to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([ + ( + "name".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "The `name:` slug of the memory file to delete".into(), + ), + ..Default::default() + }, + ), + ( + "scope".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "Scope of the file: 'global' (user-level) or 'workspace' (project-level)" + .into(), + ), + ..Default::default() + }, + ), + ])), + required: Some(vec!["name".to_string(), "scope".to_string()]), + ..Default::default() + }, + agent: false, + }, ] } @@ -214,47 +325,13 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value "workspace": store.workspace.as_ref().map(workspace_label), })) } - "write" => { - let name = arg_str(args, "name")?; - let description = arg_str(args, "description")?; - let content = arg_str(args, "content")?; - let scope = arg_str(args, "scope")?; - let kind = args.get("type").and_then(Value::as_str).map(String::from); - - let target_dir = match scope.as_str() { - "global" => paths::global_memory_dir(), - "workspace" => workspace_write_dir(&store, &cwd)?, - other => bail!("unknown scope '{}': use 'global' or 'workspace'", other), - }; - let file = MemoryFile { - path: target_dir.join(format!("{name}.md")), - frontmatter: MemoryFrontmatter { - name: name.clone(), - description: Some(description.clone()), - kind, - }, - body: content, - }; - file.save()?; - - let index_path = target_dir.join("MEMORY.md"); - let index_updated = ensure_index_entry(&index_path, &name, &description)?; - - Ok(json!({ - "status": "ok", - "path": file.path.display().to_string(), - "index_path": index_path.display().to_string(), - "index_updated": index_updated, - })) - } + "write" => write_memory(&store, &cwd, args), + "rename" => rename_memory(&store, &cwd, args), + "delete" => delete_memory(&store, &cwd, args), "edit_index" => { let scope = arg_str(args, "scope")?; let content = arg_str(args, "content")?; - let target_dir = match scope.as_str() { - "global" => paths::global_memory_dir(), - "workspace" => workspace_write_dir(&store, &cwd)?, - other => bail!("unknown scope '{}': use 'global' or 'workspace'", other), - }; + let target_dir = scope_dir(&store, &cwd, &scope)?; let index_path = write_memory_index(&target_dir, &content)?; Ok(json!({ @@ -267,19 +344,229 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value } } +fn write_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result { + let name = arg_str(args, "name")?; + let description = arg_str(args, "description")?; + let content = arg_str(args, "content")?; + let scope = arg_str(args, "scope")?; + let kind = args.get("type").and_then(Value::as_str).map(String::from); + let superseded_by = args + .get("superseded_by") + .and_then(Value::as_str) + .map(String::from); + let expires = args + .get("expires") + .and_then(Value::as_str) + .map(String::from); + + let target_dir = scope_dir(store, cwd, &scope)?; + let path = target_dir.join(format!("{name}.md")); + let previous = if path.exists() { + MemoryFile::load(&path).ok() + } else { + None + }; + let today = today_string(); + let created = previous + .as_ref() + .and_then(|p| p.frontmatter.created.clone()) + .unwrap_or_else(|| today.clone()); + + let file = MemoryFile { + path, + frontmatter: MemoryFrontmatter { + name: name.clone(), + description: Some(description.clone()), + kind, + created: Some(created), + updated: Some(today), + superseded_by, + expires, + }, + body: content, + }; + file.save()?; + + let index_path = target_dir.join("MEMORY.md"); + let index_updated = ensure_index_entry(&index_path, &name, &description)?; + + Ok(json!({ + "status": "ok", + "path": file.path.display().to_string(), + "index_path": index_path.display().to_string(), + "index_updated": index_updated, + "replaced": previous.is_some(), + "previous_description": previous.and_then(|p| p.frontmatter.description), + })) +} + +fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result { + let name = arg_str(args, "name")?; + let new_name = arg_str(args, "new_name")?; + let scope = arg_str(args, "scope")?; + if new_name.is_empty() + || !new_name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + bail!( + "invalid new_name '{}': use a kebab-case slug (alphanumeric, hyphens, underscores)", + new_name + ); + } + + if name == new_name { + bail!("new_name matches the current name"); + } + + let target_dir = scope_dir(store, cwd, &scope)?; + let files = store.list_files()?; + let file = files + .iter() + .find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name) + .ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))? + .clone(); + + if target_dir.join(format!("{new_name}.md")).exists() + || files + .iter() + .any(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == new_name) + { + bail!( + "memory file '{}' already exists in scope '{}'", + new_name, + scope + ); + } + + let needle = format!("[[{name}]]"); + let replacement = format!("[[{new_name}]]"); + + let mut renamed = file.clone(); + renamed.path = target_dir.join(format!("{new_name}.md")); + renamed.frontmatter.name = new_name.clone(); + renamed.frontmatter.updated = Some(today_string()); + renamed.body = renamed.body.replace(&needle, &replacement); + renamed.save()?; + fs::remove_file(&file.path).with_context(|| format!("remove {}", file.path.display()))?; + + let mut rewritten = Vec::new(); + for f in &files { + if f.path == file.path || !f.body.contains(&needle) { + continue; + } + let mut updated = f.clone(); + updated.body = updated.body.replace(&needle, &replacement); + updated.save()?; + rewritten.push(f.frontmatter.name.clone()); + } + + // Own-scope index: rewrite the wikilink, drop any leftover references to the + // old name, and guarantee the new name is present. + let index_path = target_dir.join("MEMORY.md"); + if let Ok(existing) = fs::read_to_string(&index_path) + && existing.contains(&needle) + { + fs::write(&index_path, existing.replace(&needle, &replacement))?; + } + + remove_index_entry(&index_path, &name)?; + let description = renamed.frontmatter.description.clone().unwrap_or_default(); + ensure_index_entry(&index_path, &new_name, &description)?; + + // Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only. + for other_index in other_index_paths(store, &target_dir) { + if let Ok(existing) = fs::read_to_string(&other_index) + && existing.contains(&needle) + { + fs::write(&other_index, existing.replace(&needle, &replacement))?; + } + } + + Ok(json!({ + "status": "ok", + "old_path": file.path.display().to_string(), + "new_path": renamed.path.display().to_string(), + "rewritten_references": rewritten, + })) +} + +fn delete_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result { + let name = arg_str(args, "name")?; + let scope = arg_str(args, "scope")?; + let target_dir = scope_dir(store, cwd, &scope)?; + let files = store.list_files()?; + let file = files + .iter() + .find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name) + .ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))?; + let deleted_path = file.path.clone(); + fs::remove_file(&deleted_path).with_context(|| format!("delete {}", deleted_path.display()))?; + + let index_path = target_dir.join("MEMORY.md"); + let index_updated = remove_index_entry(&index_path, &name)?; + + let dangling: Vec = files + .iter() + .filter(|f| f.path != deleted_path && extract_wikilinks(&f.body).iter().any(|l| l == &name)) + .map(|f| f.frontmatter.name.clone()) + .collect(); + + Ok(json!({ + "status": "ok", + "deleted_path": deleted_path.display().to_string(), + "index_updated": index_updated, + "dangling_references": dangling, + })) +} + +fn scope_dir(store: &MemoryStore, cwd: &Path, scope: &str) -> Result { + match scope { + "global" => Ok(paths::global_memory_dir()), + "workspace" => workspace_write_dir(store, cwd), + other => bail!("unknown scope '{}': use 'global' or 'workspace'", other), + } +} + +fn today_string() -> String { + Local::now().format("%Y-%m-%d").to_string() +} + +fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec { + let mut out = Vec::new(); + let global_index = store.global_dir.join("MEMORY.md"); + if store.global_dir.as_path() != own_dir && global_index.exists() { + out.push(global_index); + } + + match &store.workspace { + Some(WorkspaceMemory::Structured { dir, .. }) => { + let index = dir.join("MEMORY.md"); + if dir.as_path() != own_dir && index.exists() { + out.push(index); + } + } + Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => { + out.push(file.clone()); + } + _ => {} + } + + out +} + fn write_memory_index(target_dir: &Path, content: &str) -> Result { fs::create_dir_all(target_dir)?; let index_path = target_dir.join("MEMORY.md"); + fs::write(&index_path, content)?; + Ok(index_path) } fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Result { let existing = fs::read_to_string(index_path).unwrap_or_default(); - let already_referenced = - existing.contains(&format!("[[{name}]]")) || existing.contains(&format!("{name}.md")); - - if already_referenced { + if index_references(&existing, name) { return Ok(false); } @@ -297,6 +584,40 @@ fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Resul } fs::write(index_path, new_content)?; + + Ok(true) +} + +fn line_references(line: &str, name: &str) -> bool { + let file_name = format!("{name}.md"); + line.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_' || c == '.')) + .any(|token| token == file_name || token.trim_matches('.') == name) +} + +fn index_references(index: &str, name: &str) -> bool { + index.lines().any(|line| line_references(line, name)) +} + +fn remove_index_entry(index_path: &Path, name: &str) -> Result { + let Ok(existing) = fs::read_to_string(index_path) else { + return Ok(false); + }; + let kept: Vec<&str> = existing + .lines() + .filter(|line| !line_references(line, name)) + .collect(); + let mut new_content = kept.join("\n"); + + if existing.ends_with('\n') && !new_content.is_empty() { + new_content.push('\n'); + } + + if new_content == existing { + return Ok(false); + } + + fs::write(index_path, new_content)?; + Ok(true) } @@ -350,9 +671,11 @@ fn workspace_label(w: &WorkspaceMemory) -> Value { fn lint_memory(store: &MemoryStore) -> Result { let files = store.list_files()?; let names: HashSet<&str> = files.iter().map(|f| f.frontmatter.name.as_str()).collect(); + let today = today_string(); let mut oversized = Vec::new(); let mut broken_links = Vec::new(); + let mut stale = Vec::new(); for f in &files { if f.char_len() > PER_FILE_SOFT_CAP { oversized.push(json!({"name": &f.frontmatter.name, "chars": f.char_len()})); @@ -362,16 +685,54 @@ fn lint_memory(store: &MemoryStore) -> Result { broken_links.push(json!({"from": &f.frontmatter.name, "to": link})); } } + if let Some(target) = &f.frontmatter.superseded_by { + stale.push(json!({ + "name": &f.frontmatter.name, + "reason": "superseded", + "superseded_by": target, + "target_exists": names.contains(target.as_str()), + })); + } + + if let Some(expires) = &f.frontmatter.expires + && expires.as_str() < today.as_str() + { + stale.push(json!({ + "name": &f.frontmatter.name, + "reason": "expired", + "expires": expires, + })); + } } - let index_content = store - .load_global_index()? - .or_else(|| store.load_workspace_index().ok().flatten()) + let global_index = store.load_global_index()?.unwrap_or_default(); + let workspace_index = store + .load_workspace_index() + .ok() + .flatten() .unwrap_or_default(); let mut orphans = Vec::new(); + let mut description_drift = Vec::new(); + for f in &files { - if !index_content.contains(&f.frontmatter.name) { + let index = if f.path.starts_with(&store.global_dir) { + &global_index + } else { + &workspace_index + }; + + if !index_references(index, &f.frontmatter.name) { orphans.push(f.frontmatter.name.clone()); + } else if let (Some(index_desc), Some(file_desc)) = ( + index_description(index, &f.frontmatter.name), + f.frontmatter.description.as_deref(), + ) && index_desc != file_desc + { + description_drift.push(json!({ + "name": &f.frontmatter.name, + "index_description": index_desc, + "file_description": file_desc, + })); } } @@ -380,13 +741,26 @@ fn lint_memory(store: &MemoryStore) -> Result { "oversized": oversized, "broken_wikilinks": broken_links, "orphans": orphans, + "stale": stale, + "description_drift": description_drift, })) } +fn index_description(index: &str, name: &str) -> Option { + let marker = format!("[[{name}]]"); + index.lines().find_map(|line| { + let pos = line.find(&marker)?; + let rest = line[pos + marker.len()..].trim_start(); + let desc = rest.strip_prefix(':')?.trim(); + (!desc.is_empty()).then(|| desc.to_string()) + }) +} + fn extract_wikilinks(body: &str) -> Vec { let mut out = Vec::new(); let bytes = body.as_bytes(); let mut i = 0; + while i + 1 < bytes.len() { if bytes[i] == b'[' && bytes[i + 1] == b'[' @@ -676,4 +1050,305 @@ mod tests { let _ = fs::remove_dir_all(&root); } + + #[test] + fn line_references_requires_exact_token_match() { + assert!(line_references("- [[auth]]: description", "auth")); + assert!(line_references("- auth.md is here", "auth")); + assert!(line_references("- referenced", "referenced")); + assert!(line_references("see auth.", "auth")); + assert!(!line_references("- [[auth-flow]]: description", "auth")); + assert!(!line_references("- oauth.md legacy", "auth")); + assert!(!line_references("- preauth notes", "auth")); + } + + #[test] + fn remove_index_entry_drops_only_matching_lines() { + let root = temp_root("index_remove"); + let index = root.join("MEMORY.md"); + fs::write( + &index, + "# Memory Index\n\n- [[keep]]: stays\n- [[gone]]: removed\n", + ) + .unwrap(); + + assert!(remove_index_entry(&index, "gone").unwrap()); + let content = fs::read_to_string(&index).unwrap(); + assert!(content.contains("[[keep]]")); + assert!(!content.contains("[[gone]]")); + + assert!(!remove_index_entry(&index, "gone").unwrap()); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn lint_checks_orphans_against_own_scope_index() { + let root = temp_root("lint_scopes"); + let global = root.join("global"); + fs::create_dir_all(&global).unwrap(); + fs::write(global.join("MEMORY.md"), "- [[global-note]]: g\n").unwrap(); + fs::write( + global.join("global-note.md"), + "---\nname: global-note\n---\ng\n", + ) + .unwrap(); + + let workspace = root.join("ws"); + let structured = workspace.join(".coyote").join("memory"); + fs::create_dir_all(&structured).unwrap(); + fs::write(structured.join("MEMORY.md"), "- [[ws-note]]: w\n").unwrap(); + fs::write( + structured.join("ws-note.md"), + "---\nname: ws-note\n---\nw\n", + ) + .unwrap(); + + let store = MemoryStore { + global_dir: global, + workspace: discover_workspace_memory(&workspace), + }; + + let report = lint_memory(&store).unwrap(); + assert!( + report["orphans"].as_array().unwrap().is_empty(), + "expected no orphans, got: {report}" + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn lint_flags_stale_and_description_drift() { + let root = temp_root("lint_stale"); + let workspace = root.join("ws"); + let structured = workspace.join(".coyote").join("memory"); + fs::create_dir_all(&structured).unwrap(); + fs::write( + structured.join("MEMORY.md"), + "- [[old-plan]]: old\n- [[bygone]]: e\n- [[drifted]]: index says this\n", + ) + .unwrap(); + fs::write( + structured.join("old-plan.md"), + "---\nname: old-plan\nsuperseded_by: new-plan\n---\nx\n", + ) + .unwrap(); + fs::write( + structured.join("bygone.md"), + "---\nname: bygone\nexpires: 2000-01-01\n---\nx\n", + ) + .unwrap(); + fs::write( + structured.join("drifted.md"), + "---\nname: drifted\ndescription: file says that\n---\nx\n", + ) + .unwrap(); + + let store = MemoryStore { + global_dir: root.join("nonexistent_global"), + workspace: discover_workspace_memory(&workspace), + }; + + let report = lint_memory(&store).unwrap(); + let stale = report["stale"].as_array().unwrap(); + let reasons: Vec<(&str, &str)> = stale + .iter() + .map(|v| (v["name"].as_str().unwrap(), v["reason"].as_str().unwrap())) + .collect(); + assert!(reasons.contains(&("old-plan", "superseded"))); + assert!(reasons.contains(&("bygone", "expired"))); + let superseded = stale.iter().find(|v| v["name"] == "old-plan").unwrap(); + assert_eq!(superseded["target_exists"], false); + + let drift = report["description_drift"].as_array().unwrap(); + assert_eq!(drift.len(), 1); + assert_eq!(drift[0]["name"], "drifted"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn delete_memory_removes_file_index_entry_and_reports_dangling() { + let root = temp_root("delete"); + let workspace = root.join("ws"); + let structured = workspace.join(".coyote").join("memory"); + fs::create_dir_all(&structured).unwrap(); + fs::write( + structured.join("MEMORY.md"), + "# Memory Index\n\n- [[doomed]]: bye\n- [[linker]]: links\n", + ) + .unwrap(); + fs::write( + structured.join("doomed.md"), + "---\nname: doomed\n---\nbye\n", + ) + .unwrap(); + fs::write( + structured.join("linker.md"), + "---\nname: linker\n---\nsee [[doomed]]\n", + ) + .unwrap(); + + let store = MemoryStore { + global_dir: root.join("g"), + workspace: discover_workspace_memory(&workspace), + }; + + let args = json!({"name": "doomed", "scope": "workspace"}); + let result = delete_memory(&store, &workspace, &args).unwrap(); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["index_updated"], true); + assert!(!structured.join("doomed.md").exists()); + let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap(); + assert!(!index.contains("doomed")); + assert!(index.contains("[[linker]]")); + assert_eq!( + result["dangling_references"].as_array().unwrap(), + &vec![json!("linker")] + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_memory_moves_file_and_rewrites_references() { + let root = temp_root("rename"); + let workspace = root.join("ws"); + let structured = workspace.join(".coyote").join("memory"); + fs::create_dir_all(&structured).unwrap(); + fs::write( + structured.join("MEMORY.md"), + "# Memory Index\n\n- [[old-name]]: the plan\n- [[linker]]: links\n", + ) + .unwrap(); + fs::write( + structured.join("old-name.md"), + "---\nname: old-name\ndescription: the plan\n---\nself link [[old-name]]\n", + ) + .unwrap(); + fs::write( + structured.join("linker.md"), + "---\nname: linker\n---\nsee [[old-name]] and [[old-name-extended]]\n", + ) + .unwrap(); + + let store = MemoryStore { + global_dir: root.join("g"), + workspace: discover_workspace_memory(&workspace), + }; + + let args = json!({"name": "old-name", "new_name": "new-name", "scope": "workspace"}); + let result = rename_memory(&store, &workspace, &args).unwrap(); + + assert_eq!(result["status"], "ok"); + assert!(!structured.join("old-name.md").exists()); + let renamed = MemoryFile::load(&structured.join("new-name.md")).unwrap(); + assert_eq!(renamed.frontmatter.name, "new-name"); + assert!(renamed.body.contains("[[new-name]]")); + + let linker = fs::read_to_string(structured.join("linker.md")).unwrap(); + assert!(linker.contains("[[new-name]]")); + assert!( + linker.contains("[[old-name-extended]]"), + "unrelated links must be untouched: {linker}" + ); + + let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap(); + assert!(index.contains("- [[new-name]]: the plan")); + assert!(!index.contains("[[old-name]]")); + assert!(index.contains("[[linker]]")); + + assert_eq!( + result["rewritten_references"].as_array().unwrap(), + &vec![json!("linker")] + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_memory_rejects_collisions_and_bad_slugs() { + let root = temp_root("rename_guard"); + let workspace = root.join("ws"); + let structured = workspace.join(".coyote").join("memory"); + fs::create_dir_all(&structured).unwrap(); + fs::write(structured.join("MEMORY.md"), "- [[a]]: a\n- [[b]]: b\n").unwrap(); + fs::write(structured.join("a.md"), "---\nname: a\n---\nx\n").unwrap(); + fs::write(structured.join("b.md"), "---\nname: b\n---\nx\n").unwrap(); + + let store = MemoryStore { + global_dir: root.join("g"), + workspace: discover_workspace_memory(&workspace), + }; + + let collision = json!({"name": "a", "new_name": "b", "scope": "workspace"}); + let err = rename_memory(&store, &workspace, &collision).unwrap_err(); + assert!(err.to_string().contains("already exists")); + + let bad_slug = json!({"name": "a", "new_name": "bad name!", "scope": "workspace"}); + let err = rename_memory(&store, &workspace, &bad_slug).unwrap_err(); + assert!(err.to_string().contains("invalid new_name")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn write_memory_stamps_timestamps_and_reports_replacement() { + let root = temp_root("write_stamps"); + let workspace = root.join("ws"); + let structured = workspace.join(".coyote").join("memory"); + fs::create_dir_all(&structured).unwrap(); + fs::write(structured.join("MEMORY.md"), "# Memory Index\n").unwrap(); + + let store = MemoryStore { + global_dir: root.join("g"), + workspace: discover_workspace_memory(&workspace), + }; + + let first = json!({ + "name": "fact", + "description": "first version", + "content": "body v1", + "scope": "workspace", + "expires": "2099-01-01", + }); + let before = today_string(); + let result = write_memory(&store, &workspace, &first).unwrap(); + let after = today_string(); + assert_eq!(result["replaced"], false); + assert_eq!(result["previous_description"], Value::Null); + + let saved = MemoryFile::load(&structured.join("fact.md")).unwrap(); + let created = saved.frontmatter.created.clone().expect("created stamped"); + assert!( + created == before || created == after, + "created '{created}' should be stamped with today's date" + ); + assert_eq!(saved.frontmatter.updated, Some(created.clone())); + assert_eq!(saved.frontmatter.expires.as_deref(), Some("2099-01-01")); + assert_eq!(saved.frontmatter.superseded_by, None); + + let second = json!({ + "name": "fact", + "description": "second version", + "content": "body v2", + "scope": "workspace", + }); + let result = write_memory(&store, &workspace, &second).unwrap(); + assert_eq!(result["replaced"], true); + assert_eq!(result["previous_description"], "first version"); + + let saved = MemoryFile::load(&structured.join("fact.md")).unwrap(); + assert_eq!( + saved.frontmatter.created, + Some(created), + "creation date must be preserved across overwrites" + ); + assert!(saved.frontmatter.updated.is_some()); + assert_eq!(saved.frontmatter.expires, None); + + let _ = fs::remove_dir_all(&root); + } }