diff --git a/src/config/prompts.rs b/src/config/prompts.rs index 8c1bf68..5beda1b 100644 --- a/src/config/prompts.rs +++ b/src/config/prompts.rs @@ -11,19 +11,25 @@ pub(crate) const DEFAULT_SKILL_INSTRUCTIONS: &str = indoc! {" pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {" ## Memory A persistent memory file system survives across sessions. The MEMORY.md content shown above is - your always-on context; put universal facts (user identity, hard rules, binding feedback) directly - in MEMORY.md so they appear on every turn. Drill files hold deeper, on-demand context. + your always-on context (universal facts, hard rules, binding feedback). Drill files hold deeper, + on-demand context that you fetch with `memory__read`. Tools: - `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. + - `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. RULES: - Every interaction has two outputs: your answer AND any memory updates the conversation warrants. Don't let learnings evaporate into chat history. - - When you create or modify a drill file, also update MEMORY.md so the index stays accurate. + - 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. - 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 4a95058..9d1c26e 100644 --- a/src/function/memory.rs +++ b/src/function/memory.rs @@ -129,6 +129,41 @@ pub fn memory_function_declarations() -> Vec { }, agent: false, }, + FunctionDeclaration { + name: format!("{MEMORY_FUNCTION_PREFIX}edit_index"), + description: + "Replace the entire MEMORY.md index at the given scope. Use to add always-on facts, \ + reorganize, prune stale entries, or fix descriptions. Coyote manages the path; \ + NEVER use fs_write or any other generic file tool on MEMORY.md." + .to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([ + ( + "scope".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "Where to edit: 'global' (user-level) or 'workspace' (project-level)" + .into(), + ), + ..Default::default() + }, + ), + ( + "content".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some("Full new contents of MEMORY.md".into()), + ..Default::default() + }, + ), + ])), + required: Some(vec!["scope".to_string(), "content".to_string()]), + ..Default::default() + }, + agent: false, + }, ] } @@ -212,11 +247,33 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value "index_updated": index_updated, })) } + "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 index_path = write_memory_index(&target_dir, &content)?; + + Ok(json!({ + "status": "ok", + "path": index_path.display().to_string(), + })) + } "lint" => lint_memory(&store), _ => bail!("unknown memory action: {action}"), } } +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 = @@ -533,6 +590,39 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn write_memory_index_creates_dir_and_writes_content() { + let root = temp_root("write_index_create"); + let target = root.join("nested").join(".coyote").join("memory"); + + let path = + write_memory_index(&target, "# Workspace Memory Index\n\n- [[foo]]: hello\n").unwrap(); + + assert_eq!(path, target.join("MEMORY.md")); + assert!(path.exists()); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "# Workspace Memory Index\n\n- [[foo]]: hello\n" + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn write_memory_index_replaces_existing_content() { + let root = temp_root("write_index_replace"); + fs::create_dir_all(&root).unwrap(); + let index = root.join("MEMORY.md"); + fs::write(&index, "# Old\n\n- [[stale]]: gone\n").unwrap(); + + let path = write_memory_index(&root, "# New\n").unwrap(); + + assert_eq!(path, index); + assert_eq!(fs::read_to_string(&path).unwrap(), "# New\n"); + + let _ = fs::remove_dir_all(&root); + } + #[test] fn lint_flags_orphans_broken_links_and_oversized() { let root = temp_root("lint");