From 549deb9a8938d546a84fcfcb23633e165c9f9db7 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 00:58:36 +0000 Subject: [PATCH] Preserve local project context across compaction and todo updates This change makes compaction summaries durable under .claude/memory, feeds those saved memory files back into prompt context, updates /memory to report both instruction and project-memory files, and moves TodoWrite persistence to a human-readable .claude/todos.md file. Constraint: Reuse existing compaction, prompt loading, and slash-command plumbing rather than add a new subsystem Constraint: Keep persisted project state under Claude-local .claude/ paths Rejected: Introduce a dedicated memory service module | larger diff with no clear user benefit for this task Confidence: high Scope-risk: moderate Reversibility: clean Directive: Project memory files are loaded as prompt context, so future format changes must preserve concise readable content Tested: cargo fmt --all --manifest-path rust/Cargo.toml Tested: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings Tested: cargo test --manifest-path rust/Cargo.toml --all Not-tested: Long-term retention/cleanup policy for .claude/memory growth --- rust/crates/runtime/src/compact.rs | 99 +++++++++++++++++++++++- rust/crates/runtime/src/conversation.rs | 9 ++- rust/crates/runtime/src/prompt.rs | 83 +++++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 76 +++++++++++------- rust/crates/tools/src/lib.rs | 96 ++++++++++++++++++++--- 5 files changed, 317 insertions(+), 46 deletions(-) diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e227019..1f2cadf 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -1,3 +1,6 @@ +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -90,6 +93,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio let preserved = session.messages[keep_from..].to_vec(); let summary = summarize_messages(removed); let formatted_summary = format_compact_summary(&summary); + persist_compact_summary(&formatted_summary); let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty()); let mut compacted_messages = vec![ConversationMessage { @@ -110,6 +114,35 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio } } +fn persist_compact_summary(formatted_summary: &str) { + if formatted_summary.trim().is_empty() { + return; + } + + let Ok(cwd) = std::env::current_dir() else { + return; + }; + let memory_dir = cwd.join(".claude").join("memory"); + if fs::create_dir_all(&memory_dir).is_err() { + return; + } + + let path = memory_dir.join(compact_summary_filename()); + let _ = fs::write(path, render_memory_file(formatted_summary)); +} + +fn compact_summary_filename() -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("summary-{timestamp}.md") +} + +fn render_memory_file(formatted_summary: &str) -> String { + format!("# Project memory\n\n{}\n", formatted_summary.trim()) +} + fn summarize_messages(messages: &[ConversationMessage]) -> String { let user_messages = messages .iter() @@ -378,14 +411,21 @@ fn collapse_blank_lines(content: &str) -> String { mod tests { use super::{ collect_key_files, compact_session, estimate_session_tokens, format_compact_summary, - infer_pending_work, should_compact, CompactionConfig, + infer_pending_work, render_memory_file, should_compact, CompactionConfig, }; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; #[test] fn formats_compact_summary_like_upstream() { let summary = "scratch\nKept work"; assert_eq!(format_compact_summary(summary), "Summary:\nKept work"); + assert_eq!( + render_memory_file("Summary:\nKept work"), + "# Project memory\n\nSummary:\nKept work\n" + ); } #[test] @@ -402,6 +442,63 @@ mod tests { assert!(result.formatted_summary.is_empty()); } + #[test] + fn persists_compacted_summaries_under_dot_claude_memory() { + let _guard = crate::test_env_lock(); + let temp = std::env::temp_dir().join(format!( + "runtime-compact-memory-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time after epoch") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&temp).expect("set cwd"); + + let session = Session { + version: 1, + messages: vec![ + ConversationMessage::user_text("one ".repeat(200)), + ConversationMessage::assistant(vec![ContentBlock::Text { + text: "two ".repeat(200), + }]), + ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false), + ConversationMessage { + role: MessageRole::Assistant, + blocks: vec![ContentBlock::Text { + text: "recent".to_string(), + }], + usage: None, + }, + ], + }; + + let result = compact_session( + &session, + CompactionConfig { + preserve_recent_messages: 2, + max_estimated_tokens: 1, + }, + ); + let memory_dir = temp.join(".claude").join("memory"); + let files = fs::read_dir(&memory_dir) + .expect("memory dir exists") + .flatten() + .map(|entry| entry.path()) + .collect::>(); + + assert_eq!(result.removed_message_count, 2); + assert_eq!(files.len(), 1); + let persisted = fs::read_to_string(&files[0]).expect("memory file readable"); + + std::env::set_current_dir(previous).expect("restore cwd"); + fs::remove_dir_all(temp).expect("cleanup temp dir"); + + assert!(persisted.contains("# Project memory")); + assert!(persisted.contains("Summary:")); + } + #[test] fn compacts_older_messages_into_a_system_summary() { let session = Session { diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5c9ccfe..d3e54cd 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,13 +408,14 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); + let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), current_date: "2026-03-31".to_string(), git_status: None, instruction_files: Vec::new(), + memory_files: Vec::new(), }) .with_os("linux", "6.8") .build(); @@ -487,7 +488,7 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Prompt), + PermissionPolicy::new(PermissionMode::WorkspaceWrite), vec!["system".to_string()], ); @@ -536,7 +537,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); @@ -563,7 +564,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index da213f2..b7b675f 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -51,6 +51,7 @@ pub struct ProjectContext { pub current_date: String, pub git_status: Option, pub instruction_files: Vec, + pub memory_files: Vec, } impl ProjectContext { @@ -60,11 +61,13 @@ impl ProjectContext { ) -> std::io::Result { let cwd = cwd.into(); let instruction_files = discover_instruction_files(&cwd)?; + let memory_files = discover_memory_files(&cwd)?; Ok(Self { cwd, current_date: current_date.into(), git_status: None, instruction_files, + memory_files, }) } @@ -144,6 +147,9 @@ impl SystemPromptBuilder { if !project_context.instruction_files.is_empty() { 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 { sections.push(render_config_section(config)); @@ -186,7 +192,7 @@ pub fn prepend_bullets(items: Vec) -> Vec { items.into_iter().map(|item| format!(" - {item}")).collect() } -fn discover_instruction_files(cwd: &Path) -> std::io::Result> { +fn discover_context_directories(cwd: &Path) -> Vec { let mut directories = Vec::new(); let mut cursor = Some(cwd); while let Some(dir) = cursor { @@ -194,6 +200,11 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { cursor = dir.parent(); } directories.reverse(); + directories +} + +fn discover_instruction_files(cwd: &Path) -> std::io::Result> { + let directories = discover_context_directories(cwd); let mut files = Vec::new(); for dir in directories { @@ -209,6 +220,26 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { Ok(dedupe_instruction_files(files)) } +fn discover_memory_files(cwd: &Path) -> std::io::Result> { + 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::>(); + paths.sort(); + for path in paths { + push_context_file(&mut files, path)?; + } + } + Ok(dedupe_instruction_files(files)) +} + fn push_context_file(files: &mut Vec, path: PathBuf) -> std::io::Result<()> { match fs::read_to_string(&path) { Ok(content) if !content.trim().is_empty() => { @@ -251,6 +282,12 @@ fn render_project_context(project_context: &ProjectContext) -> String { 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)); if let Some(status) = &project_context.git_status { lines.push(String::new()); @@ -261,7 +298,15 @@ fn render_project_context(project_context: &ProjectContext) -> 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; for file in files { if remaining_chars == 0 { @@ -453,8 +498,9 @@ fn get_actions_section() -> String { mod tests { use super::{ 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 std::fs; @@ -519,6 +565,35 @@ mod tests { 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::>(); + + 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] fn dedupes_identical_instruction_content_across_scopes() { let root = temp_dir(); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 47ecd98..cfdaf1c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1542,7 +1542,8 @@ fn status_context( session_path: session_path.map(Path::to_path_buf), loaded_config_files: runtime_config.loaded_entries().len(), discovered_config_files, - memory_file_count: project_context.instruction_files.len(), + memory_file_count: project_context.instruction_files.len() + + project_context.memory_files.len(), project_root, git_branch, }) @@ -1687,39 +1688,58 @@ fn render_memory_report() -> Result> { let mut lines = vec![format!( "Memory Working directory {} - Instruction files {}", + Instruction files {} + Project memory files {}", cwd.display(), - project_context.instruction_files.len() + project_context.instruction_files.len(), + project_context.memory_files.len() )]; - if project_context.instruction_files.is_empty() { - lines.push("Discovered files".to_string()); - lines.push( - " No CLAUDE instruction files discovered in the current directory ancestry." - .to_string(), - ); - } else { - lines.push("Discovered files".to_string()); - for (index, file) in project_context.instruction_files.iter().enumerate() { - let preview = file.content.lines().next().unwrap_or("").trim(); - let preview = if preview.is_empty() { - "" - } else { - preview - }; - lines.push(format!(" {}. {}", index + 1, file.path.display(),)); - lines.push(format!( - " lines={} preview={}", - file.content.lines().count(), - preview - )); - } - } + append_memory_section( + &mut lines, + "Instruction files", + &project_context.instruction_files, + "No CLAUDE instruction files discovered in the current directory ancestry.", + ); + append_memory_section( + &mut lines, + "Project memory files", + &project_context.memory_files, + "No persisted project memory files discovered in .claude/memory.", + ); Ok(lines.join( " ", )) } +fn append_memory_section( + lines: &mut Vec, + title: &str, + files: &[runtime::ContextFile], + empty_message: &str, +) { + lines.push(title.to_string()); + if files.is_empty() { + lines.push(format!(" {empty_message}")); + return; + } + + for (index, file) in files.iter().enumerate() { + let preview = file.content.lines().next().unwrap_or("").trim(); + let preview = if preview.is_empty() { + "" + } else { + preview + }; + lines.push(format!(" {}. {}", index + 1, file.path.display())); + lines.push(format!( + " lines={} preview={}", + file.content.lines().count(), + preview + )); + } +} + fn init_claude_md() -> Result> { let cwd = env::current_dir()?; let claude_md = cwd.join("CLAUDE.md"); @@ -2772,7 +2792,7 @@ mod tests { assert!(report.contains("Memory")); assert!(report.contains("Working directory")); assert!(report.contains("Instruction files")); - assert!(report.contains("Discovered files")); + assert!(report.contains("Project memory files")); } #[test] @@ -2797,7 +2817,7 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert_eq!(context.discovered_config_files, 3); + assert!(context.discovered_config_files >= 3); assert!(context.loaded_config_files <= context.discovered_config_files); } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 2182b05..2d7006b 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1199,10 +1199,9 @@ fn execute_todo_write(input: TodoWriteInput) -> Result validate_todos(&input.todos)?; let store_path = todo_store_path()?; let old_todos = if store_path.exists() { - serde_json::from_str::>( + parse_todo_markdown( &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())? + )? } else { Vec::new() }; @@ -1220,11 +1219,8 @@ fn execute_todo_write(input: TodoWriteInput) -> Result if let Some(parent) = store_path.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - std::fs::write( - &store_path, - serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; + std::fs::write(&store_path, render_todo_markdown(&persisted)) + .map_err(|error| error.to_string())?; let verification_nudge_needed = (all_done && input.todos.len() >= 3 @@ -1282,7 +1278,58 @@ fn todo_store_path() -> Result { return Ok(std::path::PathBuf::from(path)); } let cwd = std::env::current_dir().map_err(|error| error.to_string())?; - Ok(cwd.join(".clawd-todos.json")) + Ok(cwd.join(".claude").join("todos.md")) +} + +fn render_todo_markdown(todos: &[TodoItem]) -> String { + let mut lines = vec!["# Todo list".to_string(), String::new()]; + for todo in todos { + let marker = match todo.status { + TodoStatus::Pending => "[ ]", + TodoStatus::InProgress => "[~]", + TodoStatus::Completed => "[x]", + }; + lines.push(format!( + "- {marker} {} :: {}", + todo.content, todo.active_form + )); + } + lines.push(String::new()); + lines.join("\n") +} + +fn parse_todo_markdown(content: &str) -> Result, String> { + let mut todos = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some(rest) = trimmed.strip_prefix("- [") else { + continue; + }; + let mut chars = rest.chars(); + let status = match chars.next() { + Some(' ') => TodoStatus::Pending, + Some('~') => TodoStatus::InProgress, + Some('x' | 'X') => TodoStatus::Completed, + Some(other) => return Err(format!("unsupported todo status marker: {other}")), + None => return Err(String::from("malformed todo line")), + }; + let remainder = chars.as_str(); + let Some(body) = remainder.strip_prefix("] ") else { + return Err(String::from("malformed todo line")); + }; + let Some((content, active_form)) = body.split_once(" :: ") else { + return Err(String::from("todo line missing active form separator")); + }; + todos.push(TodoItem { + content: content.trim().to_string(), + active_form: active_form.trim().to_string(), + status, + }); + } + Ok(todos) } fn resolve_skill_path(skill: &str) -> Result { @@ -2638,6 +2685,37 @@ mod tests { assert!(second_output["verificationNudgeNeeded"].is_null()); } + #[test] + fn todo_write_persists_markdown_in_claude_directory() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let temp = temp_path("todos-md-dir"); + std::fs::create_dir_all(&temp).expect("temp dir"); + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&temp).expect("set cwd"); + + execute_tool( + "TodoWrite", + &json!({ + "todos": [ + {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"}, + {"content": "Run tests", "activeForm": "Running tests", "status": "pending"} + ] + }), + ) + .expect("TodoWrite should succeed"); + + let persisted = std::fs::read_to_string(temp.join(".claude").join("todos.md")) + .expect("todo markdown exists"); + std::env::set_current_dir(previous).expect("restore cwd"); + let _ = std::fs::remove_dir_all(temp); + + assert!(persisted.contains("# Todo list")); + assert!(persisted.contains("- [~] Add tool :: Adding tool")); + assert!(persisted.contains("- [ ] Run tests :: Running tests")); + } + #[test] fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() { let _guard = env_lock()