|
@@ -51,6 +51,7 @@ pub struct ProjectContext {
|
|
|
pub current_date: String,
|
|
pub current_date: String,
|
|
|
pub git_status: Option<String>,
|
|
pub git_status: Option<String>,
|
|
|
pub instruction_files: Vec<ContextFile>,
|
|
pub instruction_files: Vec<ContextFile>,
|
|
|
|
|
+ pub memory_files: Vec<ContextFile>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl ProjectContext {
|
|
impl ProjectContext {
|
|
@@ -60,11 +61,13 @@ impl ProjectContext {
|
|
|
) -> std::io::Result<Self> {
|
|
) -> std::io::Result<Self> {
|
|
|
let cwd = cwd.into();
|
|
let cwd = cwd.into();
|
|
|
let instruction_files = discover_instruction_files(&cwd)?;
|
|
let instruction_files = discover_instruction_files(&cwd)?;
|
|
|
|
|
+ let memory_files = discover_memory_files(&cwd)?;
|
|
|
Ok(Self {
|
|
Ok(Self {
|
|
|
cwd,
|
|
cwd,
|
|
|
current_date: current_date.into(),
|
|
current_date: current_date.into(),
|
|
|
git_status: None,
|
|
git_status: None,
|
|
|
instruction_files,
|
|
instruction_files,
|
|
|
|
|
+ memory_files,
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -144,6 +147,9 @@ impl SystemPromptBuilder {
|
|
|
if !project_context.instruction_files.is_empty() {
|
|
if !project_context.instruction_files.is_empty() {
|
|
|
sections.push(render_instruction_files(&project_context.instruction_files));
|
|
sections.push(render_instruction_files(&project_context.instruction_files));
|
|
|
}
|
|
}
|
|
|
|
|
+ if !project_context.memory_files.is_empty() {
|
|
|
|
|
+ sections.push(render_memory_files(&project_context.memory_files));
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
if let Some(config) = &self.config {
|
|
if let Some(config) = &self.config {
|
|
|
sections.push(render_config_section(config));
|
|
sections.push(render_config_section(config));
|
|
@@ -186,7 +192,7 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
|
|
|
items.into_iter().map(|item| format!(" - {item}")).collect()
|
|
items.into_iter().map(|item| format!(" - {item}")).collect()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
|
|
|
|
|
|
+fn discover_context_directories(cwd: &Path) -> Vec<PathBuf> {
|
|
|
let mut directories = Vec::new();
|
|
let mut directories = Vec::new();
|
|
|
let mut cursor = Some(cwd);
|
|
let mut cursor = Some(cwd);
|
|
|
while let Some(dir) = cursor {
|
|
while let Some(dir) = cursor {
|
|
@@ -194,6 +200,11 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
|
|
cursor = dir.parent();
|
|
cursor = dir.parent();
|
|
|
}
|
|
}
|
|
|
directories.reverse();
|
|
directories.reverse();
|
|
|
|
|
+ directories
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
|
|
|
|
+ let directories = discover_context_directories(cwd);
|
|
|
|
|
|
|
|
let mut files = Vec::new();
|
|
let mut files = Vec::new();
|
|
|
for dir in directories {
|
|
for dir in directories {
|
|
@@ -209,6 +220,26 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
|
|
Ok(dedupe_instruction_files(files))
|
|
Ok(dedupe_instruction_files(files))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn discover_memory_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
|
|
|
|
+ let mut files = Vec::new();
|
|
|
|
|
+ for dir in discover_context_directories(cwd) {
|
|
|
|
|
+ let memory_dir = dir.join(".claude").join("memory");
|
|
|
|
|
+ let Ok(entries) = fs::read_dir(&memory_dir) else {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ };
|
|
|
|
|
+ let mut paths = entries
|
|
|
|
|
+ .flatten()
|
|
|
|
|
+ .map(|entry| entry.path())
|
|
|
|
|
+ .filter(|path| path.is_file())
|
|
|
|
|
+ .collect::<Vec<_>>();
|
|
|
|
|
+ paths.sort();
|
|
|
|
|
+ for path in paths {
|
|
|
|
|
+ push_context_file(&mut files, path)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(dedupe_instruction_files(files))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
|
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
|
|
match fs::read_to_string(&path) {
|
|
match fs::read_to_string(&path) {
|
|
|
Ok(content) if !content.trim().is_empty() => {
|
|
Ok(content) if !content.trim().is_empty() => {
|
|
@@ -251,6 +282,12 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
|
|
project_context.instruction_files.len()
|
|
project_context.instruction_files.len()
|
|
|
));
|
|
));
|
|
|
}
|
|
}
|
|
|
|
|
+ if !project_context.memory_files.is_empty() {
|
|
|
|
|
+ bullets.push(format!(
|
|
|
|
|
+ "Project memory files discovered: {}.",
|
|
|
|
|
+ project_context.memory_files.len()
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
lines.extend(prepend_bullets(bullets));
|
|
lines.extend(prepend_bullets(bullets));
|
|
|
if let Some(status) = &project_context.git_status {
|
|
if let Some(status) = &project_context.git_status {
|
|
|
lines.push(String::new());
|
|
lines.push(String::new());
|
|
@@ -261,7 +298,15 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn render_instruction_files(files: &[ContextFile]) -> String {
|
|
fn render_instruction_files(files: &[ContextFile]) -> String {
|
|
|
- let mut sections = vec!["# Claude instructions".to_string()];
|
|
|
|
|
|
|
+ render_context_file_section("# Claude instructions", files)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn render_memory_files(files: &[ContextFile]) -> String {
|
|
|
|
|
+ render_context_file_section("# Project memory", files)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn render_context_file_section(title: &str, files: &[ContextFile]) -> String {
|
|
|
|
|
+ let mut sections = vec![title.to_string()];
|
|
|
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
|
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
|
|
for file in files {
|
|
for file in files {
|
|
|
if remaining_chars == 0 {
|
|
if remaining_chars == 0 {
|
|
@@ -453,8 +498,9 @@ fn get_actions_section() -> String {
|
|
|
mod tests {
|
|
mod tests {
|
|
|
use super::{
|
|
use super::{
|
|
|
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
|
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
|
|
- render_instruction_content, render_instruction_files, truncate_instruction_content,
|
|
|
|
|
- ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
|
|
|
|
|
|
+ render_instruction_content, render_instruction_files, render_memory_files,
|
|
|
|
|
+ truncate_instruction_content, ContextFile, ProjectContext, SystemPromptBuilder,
|
|
|
|
|
+ SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
|
|
};
|
|
};
|
|
|
use crate::config::ConfigLoader;
|
|
use crate::config::ConfigLoader;
|
|
|
use std::fs;
|
|
use std::fs;
|
|
@@ -519,6 +565,35 @@ mod tests {
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn discovers_project_memory_files_from_ancestor_chain() {
|
|
|
|
|
+ let root = temp_dir();
|
|
|
|
|
+ let nested = root.join("apps").join("api");
|
|
|
|
|
+ fs::create_dir_all(root.join(".claude").join("memory")).expect("root memory dir");
|
|
|
|
|
+ fs::create_dir_all(nested.join(".claude").join("memory")).expect("nested memory dir");
|
|
|
|
|
+ fs::write(
|
|
|
|
|
+ root.join(".claude").join("memory").join("2026-03-30.md"),
|
|
|
|
|
+ "root memory",
|
|
|
|
|
+ )
|
|
|
|
|
+ .expect("write root memory");
|
|
|
|
|
+ fs::write(
|
|
|
|
|
+ nested.join(".claude").join("memory").join("2026-03-31.md"),
|
|
|
|
|
+ "nested memory",
|
|
|
|
|
+ )
|
|
|
|
|
+ .expect("write nested memory");
|
|
|
|
|
+
|
|
|
|
|
+ let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
|
|
|
|
+ let contents = context
|
|
|
|
|
+ .memory_files
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .map(|file| file.content.as_str())
|
|
|
|
|
+ .collect::<Vec<_>>();
|
|
|
|
|
+
|
|
|
|
|
+ assert_eq!(contents, vec!["root memory", "nested memory"]);
|
|
|
|
|
+ assert!(render_memory_files(&context.memory_files).contains("# Project memory"));
|
|
|
|
|
+ fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn dedupes_identical_instruction_content_across_scopes() {
|
|
fn dedupes_identical_instruction_content_across_scopes() {
|
|
|
let root = temp_dir();
|
|
let root = temp_dir();
|