Kaynağa Gözat

Merge remote-tracking branch 'origin/rcc/memory' into dev/rust

Yeachan-Heo 2 ay önce
ebeveyn
işleme
d3275cbe45

+ 98 - 1
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 = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
         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::<Vec<_>>();
+
+        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 {

+ 1 - 0
rust/crates/runtime/src/conversation.rs

@@ -415,6 +415,7 @@ mod tests {
                 current_date: "2026-03-31".to_string(),
                 git_status: None,
                 instruction_files: Vec::new(),
+                memory_files: Vec::new(),
             })
             .with_os("linux", "6.8")
             .build();

+ 79 - 4
rust/crates/runtime/src/prompt.rs

@@ -51,6 +51,7 @@ pub struct ProjectContext {
     pub current_date: String,
     pub git_status: Option<String>,
     pub instruction_files: Vec<ContextFile>,
+    pub memory_files: Vec<ContextFile>,
 }
 
 impl ProjectContext {
@@ -60,11 +61,13 @@ impl ProjectContext {
     ) -> std::io::Result<Self> {
         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<String>) -> Vec<String> {
     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 cursor = Some(cwd);
     while let Some(dir) = cursor {
@@ -194,6 +200,11 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
         cursor = dir.parent();
     }
     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();
     for dir in directories {
@@ -209,6 +220,26 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
     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<()> {
     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::<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]
     fn dedupes_identical_instruction_content_across_scopes() {
         let root = temp_dir();

+ 47 - 27
rust/crates/rusty-claude-cli/src/main.rs

@@ -1541,7 +1541,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,
     })
@@ -1686,39 +1687,58 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
     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() {
-                "<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<String>,
+    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() {
+            "<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<String, Box<dyn std::error::Error>> {
     let cwd = env::current_dir()?;
     let claude_md = cwd.join("CLAUDE.md");
@@ -2964,7 +2984,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]

+ 87 - 9
rust/crates/tools/src/lib.rs

@@ -1199,10 +1199,9 @@ fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String>
     validate_todos(&input.todos)?;
     let store_path = todo_store_path()?;
     let old_todos = if store_path.exists() {
-        serde_json::from_str::<Vec<TodoItem>>(
+        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<TodoWriteOutput, String>
     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<std::path::PathBuf, String> {
         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<Vec<TodoItem>, 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<std::path::PathBuf, String> {
@@ -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()