feat: initial scaffolding of a memory system
This commit is contained in:
@@ -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<WorkspaceMemory> {
|
||||||
|
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<String>,
|
||||||
|
#[serde(default, rename = "type")]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MemoryFile {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub frontmatter: MemoryFrontmatter,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryFile {
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
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<WorkspaceMemory>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<String>> {
|
||||||
|
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<Option<String>> {
|
||||||
|
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<Vec<MemoryFile>> {
|
||||||
|
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<MemoryFile>) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod input;
|
|||||||
mod install_remote;
|
mod install_remote;
|
||||||
mod macros;
|
mod macros;
|
||||||
mod mcp_factory;
|
mod mcp_factory;
|
||||||
|
pub(crate) mod memory;
|
||||||
pub(crate) mod paths;
|
pub(crate) mod paths;
|
||||||
pub(crate) mod prompts;
|
pub(crate) mod prompts;
|
||||||
mod rag_cache;
|
mod rag_cache;
|
||||||
@@ -138,6 +139,10 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
|
|||||||
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
||||||
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
||||||
const MCP_FILE_NAME: &str = "mcp.json";
|
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] = [
|
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||||
"execute_command.sh",
|
"execute_command.sh",
|
||||||
"execute_py_code.py",
|
"execute_py_code.py",
|
||||||
|
|||||||
+9
-6
@@ -1,10 +1,5 @@
|
|||||||
use super::role::Role;
|
use super::role::Role;
|
||||||
use super::{
|
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};
|
||||||
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 crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
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")
|
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<PathBuf>)> {
|
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
|
||||||
let log_level = env::var(get_env_name("log_level"))
|
let log_level = env::var(get_env_name("log_level"))
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
Reference in New Issue
Block a user