use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{context::ContextScope, frontmatter, named_roots};
pub const MEMORY_DIR: &str = "memory";
pub const MEMORY_EXTENSION: &str = "md";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryConfig {
pub global_root: Option<PathBuf>,
pub workspace_root: WorkspaceMemoryRoot,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
global_root: crate::context::ContextConfig::default()
.global_dir
.map(|dir| dir.join(MEMORY_DIR)),
workspace_root: WorkspaceMemoryRoot::BesideStore,
}
}
}
impl MemoryConfig {
pub fn disabled() -> Self {
Self {
global_root: None,
workspace_root: WorkspaceMemoryRoot::Off,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceMemoryRoot {
BesideStore,
Dir(PathBuf),
Off,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemorySource {
pub path: PathBuf,
pub scope: ContextScope,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Memory {
pub name: String,
pub description: String,
pub kind: MemoryKind,
pub path: PathBuf,
pub scope: ContextScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryKind {
User,
Feedback,
Project,
Reference,
}
#[derive(Debug, Error)]
pub enum MemoryError {
#[error("failed to read memory directory {path}: {source}")]
ReadDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to read memory file {path}: {source}")]
ReadFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"memory {path} has no frontmatter; a memory opens with `---` and names \
`name`, `description` and `type`"
)]
MissingFrontmatter { path: PathBuf },
#[error("invalid memory frontmatter in {path}: {message}")]
InvalidFrontmatter { path: PathBuf, message: String },
#[error("duplicate memory name '{name}' in {first_path} and {second_path}")]
DuplicateName {
name: String,
first_path: PathBuf,
second_path: PathBuf,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Frontmatter {
name: String,
description: String,
#[serde(rename = "type")]
kind: MemoryKind,
}
pub(crate) fn roots(config: &MemoryConfig, store_dir: Option<&Path>) -> Vec<MemorySource> {
let mut sources = Vec::new();
let workspace_root = match &config.workspace_root {
WorkspaceMemoryRoot::BesideStore => store_dir
.and_then(Path::parent)
.map(|parent| parent.join(MEMORY_DIR)),
WorkspaceMemoryRoot::Dir(path) => Some(path.clone()),
WorkspaceMemoryRoot::Off => None,
};
if let Some(path) = workspace_root {
sources.push(MemorySource {
path,
scope: ContextScope::Workspace,
});
}
if let Some(global) = &config.global_root
&& !sources
.iter()
.any(|source| crate::paths::same_dir(&source.path, global))
{
sources.push(MemorySource {
path: global.clone(),
scope: ContextScope::Global,
});
}
sources
}
pub(crate) fn load(sources: &[MemorySource]) -> Result<Vec<Memory>, MemoryError> {
named_roots::merge_roots(
sources
.iter()
.map(|source| load_root(&source.path, &source.scope)),
)
}
fn load_root(root: &Path, scope: &ContextScope) -> Result<BTreeMap<String, Memory>, MemoryError> {
if !root.is_dir() {
return Ok(BTreeMap::new());
}
let entries = fs::read_dir(root).map_err(|source| MemoryError::ReadDir {
path: root.to_path_buf(),
source,
})?;
let mut paths = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| MemoryError::ReadDir {
path: root.to_path_buf(),
source,
})?;
let path = entry.path();
let is_file = entry.file_type().is_ok_and(|kind| kind.is_file());
if is_file && is_memory_file(&path) {
paths.push(path);
}
}
named_roots::load_root(
paths,
|path| {
let memory = read_memory(path, scope)?;
Ok((memory.name.clone(), memory))
},
|name, first_path, second_path| MemoryError::DuplicateName {
name,
first_path,
second_path,
},
)
}
fn read_memory(path: &Path, scope: &ContextScope) -> Result<Memory, MemoryError> {
let raw = fs::read_to_string(path).map_err(|source| MemoryError::ReadFile {
path: path.to_path_buf(),
source,
})?;
let scanned = frontmatter::scan(&raw).map_err(|frontmatter::Unterminated| {
MemoryError::InvalidFrontmatter {
path: path.to_path_buf(),
message: "missing closing frontmatter delimiter".to_string(),
}
})?;
let block = match scanned.frontmatter {
Some(block) if !block.trim().is_empty() => block,
_ => {
return Err(MemoryError::MissingFrontmatter {
path: path.to_path_buf(),
});
}
};
let meta: Frontmatter =
serde_yaml_ng::from_str(block).map_err(|error| MemoryError::InvalidFrontmatter {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let name = spoken_field(path, &meta.name, "name")?;
let description = spoken_field(path, &meta.description, "description")?;
Ok(Memory {
name,
description,
kind: meta.kind,
path: path.to_path_buf(),
scope: scope.clone(),
})
}
fn spoken_field(path: &Path, value: &str, key: &str) -> Result<String, MemoryError> {
let value = value.trim();
if value.is_empty() {
return Err(MemoryError::InvalidFrontmatter {
path: path.to_path_buf(),
message: format!("`{key}` is empty"),
});
}
Ok(value.to_string())
}
fn is_memory_file(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case(MEMORY_EXTENSION))
}
pub(crate) fn index_block(memories: &[Memory]) -> Option<String> {
if memories.is_empty() {
return None;
}
let entries = memories
.iter()
.map(|memory| {
format!(
"- {} — {} ({})",
memory.name,
memory.description,
memory.path.display()
)
})
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"<memories>\n{entries}\n</memories>\n\n{}",
include_str!("memory/instructions.md").trim_end()
))
}
pub fn file_contents(name: &str, description: &str, kind: MemoryKind, body: &str) -> String {
let meta = serde_yaml_ng::to_string(&Frontmatter {
name: name.to_string(),
description: description.to_string(),
kind,
})
.expect("three string-or-enum fields always serialize");
format!("---\n{meta}---\n\n{}\n", body.trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
fs::create_dir_all(dir).expect("create dir");
let path = dir.join(name);
fs::write(&path, body).expect("write file");
path
}
fn memory_file(name: &str) -> String {
format!("---\nname: {name}\ndescription: about {name}\ntype: project\n---\nbody\n")
}
fn source(path: &Path, scope: ContextScope) -> MemorySource {
MemorySource {
path: path.to_path_buf(),
scope,
}
}
fn config(global: Option<PathBuf>, workspace: WorkspaceMemoryRoot) -> MemoryConfig {
MemoryConfig {
global_root: global,
workspace_root: workspace,
}
}
#[test]
fn the_workspace_root_is_the_siblings_memory_directory_beside_the_store() {
let found = roots(
&config(None, WorkspaceMemoryRoot::BesideStore),
Some(Path::new("/data/workspaces/abc/store")),
);
assert_eq!(found.len(), 1);
assert_eq!(found[0].path, PathBuf::from("/data/workspaces/abc/memory"));
assert_eq!(found[0].scope, ContextScope::Workspace);
}
#[test]
fn no_store_dir_means_no_workspace_root() {
assert!(roots(&config(None, WorkspaceMemoryRoot::BesideStore), None).is_empty());
}
#[test]
fn an_explicit_workspace_root_is_used_as_given() {
let found = roots(
&config(None, WorkspaceMemoryRoot::Dir(PathBuf::from("/elsewhere"))),
Some(Path::new("/data/store")),
);
assert_eq!(found.len(), 1);
assert_eq!(found[0].path, PathBuf::from("/elsewhere"));
}
#[test]
fn the_workspace_root_outranks_the_global_one() {
let found = roots(
&config(
Some(PathBuf::from("/home/config/memory")),
WorkspaceMemoryRoot::Dir(PathBuf::from("/work/memory")),
),
None,
);
let scopes: Vec<&ContextScope> = found.iter().map(|source| &source.scope).collect();
assert_eq!(
scopes,
vec![&ContextScope::Workspace, &ContextScope::Global]
);
}
#[test]
fn disabled_resolves_no_roots_at_all() {
assert!(roots(&MemoryConfig::disabled(), Some(Path::new("/data/store"))).is_empty());
}
#[test]
fn one_directory_reached_by_both_roots_is_one_source() {
let tmp = tempfile::tempdir().expect("tempdir");
let found = roots(
&config(
Some(tmp.path().to_path_buf()),
WorkspaceMemoryRoot::Dir(tmp.path().to_path_buf()),
),
None,
);
assert_eq!(found.len(), 1);
assert_eq!(found[0].scope, ContextScope::Workspace);
}
#[test]
fn a_missing_root_contributes_nothing() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("never-written");
let loaded =
load(&[source(&missing, ContextScope::Workspace)]).expect("absent is not an error");
assert!(loaded.is_empty());
}
#[test]
fn a_memory_carries_its_frontmatter_and_not_its_body() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = write(
tmp.path(),
"deploy.md",
"---\nname: deploy-notes\ndescription: how deploys go out\ntype: project\n---\nlong body\n",
);
let loaded = load(&[source(tmp.path(), ContextScope::Workspace)]).expect("loads");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "deploy-notes");
assert_eq!(loaded[0].description, "how deploys go out");
assert_eq!(loaded[0].kind, MemoryKind::Project);
assert_eq!(loaded[0].path, path);
assert_eq!(loaded[0].scope, ContextScope::Workspace);
}
#[test]
fn a_workspace_memory_shadows_a_global_one_of_the_same_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let global = tmp.path().join("global");
write(
&workspace,
"a.md",
"---\nname: deploy\ndescription: workspace's\ntype: project\n---\n",
);
write(
&global,
"b.md",
"---\nname: deploy\ndescription: global's\ntype: project\n---\n",
);
write(&global, "c.md", &memory_file("only-global"));
let loaded = load(&[
source(&workspace, ContextScope::Workspace),
source(&global, ContextScope::Global),
])
.expect("loads");
assert_eq!(loaded.len(), 2);
let deploy = loaded.iter().find(|m| m.name == "deploy").expect("deploy");
assert_eq!(deploy.description, "workspace's");
assert_eq!(deploy.scope, ContextScope::Workspace);
assert!(loaded.iter().any(|m| m.name == "only-global"));
}
#[test]
fn two_files_claiming_one_name_in_a_single_root_is_an_error() {
let tmp = tempfile::tempdir().expect("tempdir");
write(tmp.path(), "a.md", &memory_file("same"));
write(tmp.path(), "b.md", &memory_file("same"));
let error = load(&[source(tmp.path(), ContextScope::Workspace)]).expect_err("rejected");
assert!(matches!(error, MemoryError::DuplicateName { .. }));
assert!(error.to_string().contains("same"));
}
#[test]
fn a_file_without_frontmatter_is_an_error_naming_the_file() {
let tmp = tempfile::tempdir().expect("tempdir");
write(tmp.path(), "bare.md", "just prose, no frontmatter\n");
let error = load(&[source(tmp.path(), ContextScope::Workspace)]).expect_err("rejected");
assert!(matches!(error, MemoryError::MissingFrontmatter { .. }));
assert!(error.to_string().contains("bare.md"));
}
#[test]
fn malformed_yaml_is_an_error_naming_the_file() {
let tmp = tempfile::tempdir().expect("tempdir");
write(tmp.path(), "bad.md", "---\nname: [unclosed\n---\nbody\n");
let error = load(&[source(tmp.path(), ContextScope::Workspace)]).expect_err("rejected");
assert!(matches!(error, MemoryError::InvalidFrontmatter { .. }));
assert!(error.to_string().contains("bad.md"));
}
#[test]
fn a_type_outside_the_set_is_an_error_naming_the_file() {
let tmp = tempfile::tempdir().expect("tempdir");
write(
tmp.path(),
"odd.md",
"---\nname: odd\ndescription: d\ntype: whimsy\n---\n",
);
let error = load(&[source(tmp.path(), ContextScope::Workspace)]).expect_err("rejected");
assert!(matches!(error, MemoryError::InvalidFrontmatter { .. }));
assert!(error.to_string().contains("odd.md"));
}
#[test]
fn a_blank_name_counts_as_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
write(
tmp.path(),
"blank.md",
"---\nname: \" \"\ndescription: d\ntype: user\n---\n",
);
let error = load(&[source(tmp.path(), ContextScope::Workspace)]).expect_err("rejected");
assert!(matches!(error, MemoryError::InvalidFrontmatter { .. }));
assert!(error.to_string().contains("`name` is empty"));
}
#[test]
fn unknown_keys_are_ignored_rather_than_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
write(
tmp.path(),
"future.md",
"---\nname: future\ndescription: d\ntype: reference\nfrom-the-future: yes\n---\n",
);
let loaded = load(&[source(tmp.path(), ContextScope::Workspace)]).expect("loads");
assert_eq!(loaded[0].name, "future");
}
#[test]
fn non_markdown_files_are_not_memories() {
let tmp = tempfile::tempdir().expect("tempdir");
write(tmp.path(), "notes.txt", "not a memory");
write(tmp.path(), ".gitkeep", "");
write(tmp.path(), "real.md", &memory_file("real"));
let loaded = load(&[source(tmp.path(), ContextScope::Workspace)]).expect("loads");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "real");
}
#[test]
fn zero_memories_render_no_block_at_all() {
assert_eq!(index_block(&[]), None);
}
#[test]
fn the_block_lists_name_description_and_path_and_carries_the_instructions() {
let memories = vec![Memory {
name: "deploy-notes".to_string(),
description: "how deploys go out".to_string(),
kind: MemoryKind::Project,
path: PathBuf::from("/mem/deploy.md"),
scope: ContextScope::Global,
}];
let block = index_block(&memories).expect("renders");
assert!(block.contains("<memories>"));
assert!(block.contains("- deploy-notes — how deploys go out (/mem/deploy.md)"));
assert!(block.contains("frontmatter"));
}
#[test]
fn file_contents_round_trips_through_the_loader() {
let tmp = tempfile::tempdir().expect("tempdir");
let written = file_contents(
"ci-flake",
"the hooks suite flakes: rerun alone before blaming a change",
MemoryKind::Feedback,
"Details worth keeping.",
);
write(tmp.path(), "ci-flake.md", &written);
let loaded = load(&[source(tmp.path(), ContextScope::Workspace)]).expect("loads");
assert_eq!(loaded[0].name, "ci-flake");
assert_eq!(loaded[0].kind, MemoryKind::Feedback);
}
#[test]
fn file_contents_survives_a_description_with_a_colon() {
let tmp = tempfile::tempdir().expect("tempdir");
let written = file_contents(
"note",
"remember: the parser is strict",
MemoryKind::User,
"body",
);
write(tmp.path(), "note.md", &written);
let loaded = load(&[source(tmp.path(), ContextScope::Workspace)]).expect("loads");
assert_eq!(loaded[0].description, "remember: the parser is strict");
}
}