From b2d70a3fd30884ee2b935b32d63dfbf48cc33afc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 10 Jun 2026 17:24:45 -0600 Subject: [PATCH] feat: initial scaffolding of a memory system --- src/config/memory.rs | 247 +++++++++++++++++++++++++++++++++++++++++++ src/config/mod.rs | 5 + src/config/paths.rs | 15 +-- 3 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 src/config/memory.rs diff --git a/src/config/memory.rs b/src/config/memory.rs new file mode 100644 index 0000000..4d90dfc --- /dev/null +++ b/src/config/memory.rs @@ -0,0 +1,247 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::config::{paths, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME}; + +#[derive(Debug, Clone)] +pub enum WorkspaceMemory { + Structured { workspace_root: PathBuf, dir: PathBuf }, + Lite { workspace_root: PathBuf, file: PathBuf }, +} + +pub fn discover_workspace_memory(start: &Path) -> Option { + for dir in start.ancestors() { + let structured = dir + .join(WORKSPACE_MEMORY_DIR_NAME) + .join(MEMORY_DIR_NAME); + if structured.join(MEMORY_INDEX_FILE_NAME).exists() { + return Some(WorkspaceMemory::Structured { + workspace_root: dir.to_path_buf(), + dir: structured, + }); + } + + let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME); + if lite.exists() { + return Some(WorkspaceMemory::Lite { + workspace_root: dir.to_path_buf(), + file: lite, + }); + } + } + None +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct MemoryFrontmatter { + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default, rename = "type")] + pub kind: Option, +} + +#[derive(Debug, Clone)] +pub struct MemoryFile { + pub path: PathBuf, + pub frontmatter: MemoryFrontmatter, + pub body: String, +} + +impl MemoryFile { + pub fn load(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("read memory file {}", path.display()))?; + let (frontmatter, body) = parse_frontmatter(&raw) + .with_context(|| format!("parse frontmatter in {}", path.display()))?; + + Ok(Self { + path: path.to_path_buf(), + frontmatter, + body, + }) + } + + pub fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + + let frontmatter_yaml = serde_yaml::to_string(&self.frontmatter)?; + let content = format!("---\n{}---\n\n{}", frontmatter_yaml, self.body); + + fs::write(&self.path, content)?; + + Ok(()) + } + + pub fn char_len(&self) -> usize { + self.body.chars().count() + } +} + +fn parse_frontmatter(raw: &str) -> Result<(MemoryFrontmatter, String)> { + let trimmed = raw.trim_start(); + if !trimmed.starts_with("---") { + return Ok((MemoryFrontmatter::default(), raw.to_string())); + } + + let after = &trimmed[3..]; + let Some(end) = after.find("\n---") else { + return Ok((MemoryFrontmatter::default(), raw.to_string())); + }; + let yaml = &after[..end]; + let body = after[end + 4..].trim_start_matches('\n').to_string(); + let frontmatter: MemoryFrontmatter = + serde_yaml::from_str(yaml.trim()).context("parse YAML frontmatter")?; + + Ok((frontmatter, body)) +} + +#[derive(Debug, Clone)] +pub struct MemoryStore { + pub global_dir: PathBuf, + pub workspace: Option, +} + +impl MemoryStore { + pub fn new(cwd: &Path) -> Self { + Self { + global_dir: paths::global_memory_dir(), + workspace: discover_workspace_memory(cwd), + } + } + + pub fn load_global_index(&self) -> Result> { + let path = self.global_dir.join(MEMORY_INDEX_FILE_NAME); + + if path.exists() { + Ok(Some(fs::read_to_string(path)?)) + } else { + Ok(None) + } + } + + pub fn load_workspace_index(&self) -> Result> { + match &self.workspace { + None => Ok(None), + Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)), + Some(WorkspaceMemory::Structured { dir, .. }) => { + let index = dir.join(MEMORY_INDEX_FILE_NAME); + if index.exists() { + Ok(Some(fs::read_to_string(index)?)) + } else { + Ok(None) + } + } + } + } + + pub fn list_files(&self) -> Result> { + let mut out = Vec::new(); + + if self.global_dir.exists() { + collect_md_files(&self.global_dir, &mut out)?; + } + + if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace { + collect_md_files(dir, &mut out)?; + } + + Ok(out) + } +} + +fn collect_md_files(dir: &Path, out: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + + if path.file_name().and_then(|n| n.to_str()) == Some(MEMORY_INDEX_FILE_NAME) { + continue; + } + + match MemoryFile::load(&path) { + Ok(f) => out.push(f), + Err(e) => log::warn!("skip malformed memory file {}: {}", path.display(), e), + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{env, time}; + use time::SystemTime; + + fn temp_root(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-memory-{label}-{unique}")); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn loads_global_and_workspace_indexes_from_test_dirs() { + let root = temp_root("phase1"); + let workspace = root.join("workspace"); + let workspace_memory_dir = workspace + .join(WORKSPACE_MEMORY_DIR_NAME) + .join(MEMORY_DIR_NAME); + fs::create_dir_all(&workspace_memory_dir).unwrap(); + fs::write( + workspace_memory_dir.join(MEMORY_INDEX_FILE_NAME), + "workspace-content", + ) + .unwrap(); + + let global = root.join("global"); + fs::create_dir_all(&global).unwrap(); + fs::write(global.join(MEMORY_INDEX_FILE_NAME), "global-content").unwrap(); + + let store = MemoryStore { + global_dir: global, + workspace: discover_workspace_memory(&workspace), + }; + + assert_eq!( + store.load_global_index().unwrap().as_deref(), + Some("global-content") + ); + assert_eq!( + store.load_workspace_index().unwrap().as_deref(), + Some("workspace-content") + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn workspace_discovery_prefers_structured_over_lite() { + let root = temp_root("prefer"); + 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), "s").unwrap(); + fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap(); + + let found = discover_workspace_memory(&workspace); + assert!(matches!(found, Some(WorkspaceMemory::Structured { .. }))); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 5b54539..b7217ed 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -5,6 +5,7 @@ mod input; mod install_remote; mod macros; mod mcp_factory; +pub(crate) mod memory; pub(crate) mod paths; pub(crate) mod prompts; mod rag_cache; @@ -138,6 +139,10 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools"; const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils"; const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh"; const MCP_FILE_NAME: &str = "mcp.json"; +const MEMORY_DIR_NAME: &str = "memory"; +const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; +const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; +const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote"; const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [ "execute_command.sh", "execute_py_code.py", diff --git a/src/config/paths.rs b/src/config/paths.rs index b50b11b..98e010d 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -1,10 +1,5 @@ use super::role::Role; -use super::{ - AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, - ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, - GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, - ROLES_DIR_NAME, SKILLS_DIR_NAME, -}; +use super::{AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SKILLS_DIR_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME}; use crate::client::ProviderModels; use crate::utils::{get_env_name, list_file_names, normalize_env_name}; @@ -195,6 +190,14 @@ pub fn models_override_file() -> PathBuf { local_path("models-override.yaml") } +pub fn global_memory_dir() -> PathBuf { + config_dir().join(MEMORY_DIR_NAME) +} + +pub fn global_memory_index_path() -> PathBuf { + global_memory_dir().join(MEMORY_INDEX_FILE_NAME) +} + pub fn log_config() -> Result<(LevelFilter, Option)> { let log_level = env::var(get_env_name("log_level")) .ok()