diff --git a/src/config/memory.rs b/src/config/memory.rs index 4d90dfc..d12e064 100644 --- a/src/config/memory.rs +++ b/src/config/memory.rs @@ -2,10 +2,14 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use log::warn; use serde::{Deserialize, Serialize}; use crate::config::{paths, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME}; +pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; +pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000; + #[derive(Debug, Clone)] pub enum WorkspaceMemory { Structured { workspace_root: PathBuf, dir: PathBuf }, @@ -156,6 +160,73 @@ impl MemoryStore { } } +pub fn build_memory_section( + store: &MemoryStore, + with_tools: bool, + cap: usize, +) -> Result> { + let global_index = store.load_global_index()?; + let workspace_index = store.load_workspace_index()?; + + if global_index.is_none() && workspace_index.is_none() { + return Ok(None); + } + + let mut buf = String::from("\n"); + let mut consumed = 0usize; + + if let Some(s) = &global_index { + buf.push_str("\n"); + buf.push_str(s); + buf.push_str("\n\n"); + consumed += s.chars().count(); + } + + if let Some(s) = &workspace_index { + buf.push_str("\n"); + buf.push_str(s); + buf.push_str("\n\n"); + consumed += s.chars().count(); + } + + if consumed > cap { + warn!( + "memory indexes ({} chars) exceed cap ({} chars); injecting fully - \ + consider raising memory_cap_* in config or shrinking MEMORY.md", + consumed, + cap + ); + } + + if !with_tools { + let mut budget = cap.saturating_sub(consumed); + let mut files = store.list_files()?; + files.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name)); + let mut omitted = 0usize; + for f in files { + let needed = f.body.chars().count() + 50; + if needed > budget { + omitted += 1; + continue; + } + buf.push_str(&format!("\n", f.frontmatter.name)); + buf.push_str(&f.body); + buf.push_str("\n\n"); + budget = budget.saturating_sub(needed); + } + + if omitted > 0 { + buf.push_str(&format!( + "\n", + omitted + )); + } + } + + buf.push_str(""); + Ok(Some(buf)) +} + fn collect_md_files(dir: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; @@ -170,7 +241,7 @@ fn collect_md_files(dir: &Path, out: &mut Vec) -> Result<()> { match MemoryFile::load(&path) { Ok(f) => out.push(f), - Err(e) => log::warn!("skip malformed memory file {}: {}", path.display(), e), + Err(e) => warn!("skip malformed memory file {}: {}", path.display(), e), } } @@ -244,4 +315,116 @@ mod tests { let _ = fs::remove_dir_all(&root); } + + #[test] + fn build_memory_section_returns_none_when_no_memory_exists() { + let root = temp_root("none"); + let workspace = root.join("ws"); + fs::create_dir_all(&workspace).unwrap(); + + let store = MemoryStore { + global_dir: root.join("global"), + workspace: discover_workspace_memory(&workspace), + }; + + assert!(build_memory_section(&store, true, 6_000).unwrap().is_none()); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn build_memory_section_injects_only_indexes_with_tools_on() { + let root = temp_root("indexes_only"); + let workspace = root.join("ws"); + let structured = workspace + .join(WORKSPACE_MEMORY_DIR_NAME) + .join(MEMORY_DIR_NAME); + fs::create_dir_all(&structured).unwrap(); + fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "workspace-index-content").unwrap(); + fs::write( + structured.join("foo.md"), + "---\nname: foo\n---\nfoo body that should not appear\n", + ) + .unwrap(); + + let store = MemoryStore { + global_dir: root.join("global"), + workspace: discover_workspace_memory(&workspace), + }; + + let section = build_memory_section(&store, true, 6_000) + .unwrap() + .expect("memory section should exist"); + assert!(section.contains("workspace-index-content")); + assert!(!section.contains("foo body that should not appear")); + assert!(section.starts_with("")); + assert!(section.ends_with("")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn build_memory_section_injects_drill_bodies_alphabetically_without_tools() { + let root = temp_root("drill_bodies"); + let workspace = root.join("ws"); + let structured = workspace + .join(WORKSPACE_MEMORY_DIR_NAME) + .join(MEMORY_DIR_NAME); + fs::create_dir_all(&structured).unwrap(); + fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); + fs::write( + structured.join("zebra.md"), + "---\nname: zebra\n---\nzebra body\n", + ) + .unwrap(); + fs::write( + structured.join("alpha.md"), + "---\nname: alpha\n---\nalpha body\n", + ) + .unwrap(); + + let store = MemoryStore { + global_dir: root.join("global"), + workspace: discover_workspace_memory(&workspace), + }; + + let section = build_memory_section(&store, false, 6_000) + .unwrap() + .expect("memory section should exist"); + let alpha_pos = section.find("alpha body").expect("alpha body missing"); + let zebra_pos = section.find("zebra body").expect("zebra body missing"); + assert!(alpha_pos < zebra_pos, "drill bodies must be alphabetical"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn build_memory_section_omits_drill_bodies_when_cap_exceeded() { + let root = temp_root("cap"); + let workspace = root.join("ws"); + let structured = workspace + .join(WORKSPACE_MEMORY_DIR_NAME) + .join(MEMORY_DIR_NAME); + fs::create_dir_all(&structured).unwrap(); + fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); + let big_body = "x".repeat(200); + fs::write( + structured.join("big.md"), + format!("---\nname: big\n---\n{}\n", big_body), + ) + .unwrap(); + + let store = MemoryStore { + global_dir: root.join("global"), + workspace: discover_workspace_memory(&workspace), + }; + + let section = build_memory_section(&store, false, 100) + .unwrap() + .expect("memory section should exist"); + assert!(!section.contains(&big_body)); + assert!(section.contains("memory file(s) omitted")); + + let _ = fs::remove_dir_all(&root); + } } diff --git a/src/config/prompts.rs b/src/config/prompts.rs index d230543..7f0eb67 100644 --- a/src/config/prompts.rs +++ b/src/config/prompts.rs @@ -8,6 +8,37 @@ pub(crate) const DEFAULT_SKILL_INSTRUCTIONS: &str = indoc! {" complete to keep the context lean." }; +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. + + 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'). + - `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. + - 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. + - Keep individual drill files focused (under ~2K chars). Split large topics across linked files." +}; + +pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS_READONLY: &str = indoc! {" + ## Memory (read-only) + The memory content shown above persists across sessions. In this session it is READ-ONLY — the user + maintains memory files manually outside the conversation. + + Reference the memory content as authoritative context about the user and their workspace. + Do not propose writing to memory or call any `memory__*` tools — they are unavailable." +}; + pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {" ## Task Tracking You have built-in task tracking tools. Use them to track your progress: diff --git a/src/config/request_context.rs b/src/config/request_context.rs index aab8e19..06130b4 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -5,12 +5,7 @@ use super::skill_policy::SkillPolicy; use super::skill_registry::SkillRegistry; use super::todo::TodoList; use super::tool_scope::{McpRuntime, ToolScope}; -use super::{ - AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE, - Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, RIGHT_PROMPT, Role, - RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, - TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, paths, -}; +use super::{AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE, Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, RIGHT_PROMPT, Role, RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, paths, memory}; use super::{MessageContentToolCalls, prompts}; use crate::client::{Model, ModelType, list_models}; use crate::function::{ @@ -46,7 +41,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{env, fs}; -use super::memory::{WorkspaceMemory, MemoryStore}; +use super::memory::{WorkspaceMemory, MemoryStore, DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS}; pub struct AutoContinueConfig { pub enabled: bool, @@ -682,6 +677,35 @@ impl RequestContext { } } + if self.should_inject_memory() + && let Some(cwd) = env::current_dir().ok() + { + let store = MemoryStore::new(&cwd); + let with_tools = self.should_register_memory_tools(); + let cap = if with_tools { + app.memory_cap_with_tools + .unwrap_or(DEFAULT_MEMORY_CAP_WITH_TOOLS) + } else { + app.memory_cap_without_tools + .unwrap_or(DEFAULT_MEMORY_CAP_WITHOUT_TOOLS) + }; + match memory::build_memory_section(&store, with_tools, cap) { + Ok(Some(section)) => { + let separator = if role.is_empty_prompt() { "" } else { "\n\n" }; + role.append_to_prompt(separator); + role.append_to_prompt(§ion); + role.append_to_prompt("\n\n"); + role.append_to_prompt(if with_tools { + prompts::DEFAULT_MEMORY_INSTRUCTIONS + } else { + prompts::DEFAULT_MEMORY_INSTRUCTIONS_READONLY + }); + } + Ok(None) => {} + Err(e) => warn!("memory injection failed: {}", e), + } + } + Ok(self.skill_registry.effective_role(&role, &policy)) } @@ -3225,6 +3249,31 @@ mod tests { assert!(!Arc::ptr_eq(&ctx.app.config, &previous)); } + #[test] + fn memory_config_app_some_false_disables_via_cascade() { + let mut ctx = create_test_ctx(); + + ctx.update_app_config(|app| app.memory = Some(false)); + + assert!( + !ctx.should_inject_memory(), + "AppConfig.memory=Some(false) must disable memory regardless of on-disk content (this is the --no-memory CLI path)" + ); + } + + #[test] + fn memory_config_role_false_beats_app_true_in_cascade() { + let mut ctx = create_test_ctx(); + ctx.update_app_config(|app| app.memory = Some(true)); + let role = Role::new("memory_off_role", "---\nmemory: false\n---\n"); + assert_eq!(role.memory(), Some(false), "metadata parser sanity check"); + ctx.role = Some(role); + assert!( + !ctx.should_inject_memory(), + "Role::memory=Some(false) must win over AppConfig::memory=Some(true)" + ); + } + #[test] fn use_role_obj_sets_role() { let mut ctx = create_test_ctx();