| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582 |
- use std::fs;
- use std::time::{SystemTime, UNIX_EPOCH};
- use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub struct CompactionConfig {
- pub preserve_recent_messages: usize,
- pub max_estimated_tokens: usize,
- }
- impl Default for CompactionConfig {
- fn default() -> Self {
- Self {
- preserve_recent_messages: 4,
- max_estimated_tokens: 10_000,
- }
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct CompactionResult {
- pub summary: String,
- pub formatted_summary: String,
- pub compacted_session: Session,
- pub removed_message_count: usize,
- }
- #[must_use]
- pub fn estimate_session_tokens(session: &Session) -> usize {
- session.messages.iter().map(estimate_message_tokens).sum()
- }
- #[must_use]
- pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
- session.messages.len() > config.preserve_recent_messages
- && estimate_session_tokens(session) >= config.max_estimated_tokens
- }
- #[must_use]
- pub fn format_compact_summary(summary: &str) -> String {
- let without_analysis = strip_tag_block(summary, "analysis");
- let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") {
- without_analysis.replace(
- &format!("<summary>{content}</summary>"),
- &format!("Summary:\n{}", content.trim()),
- )
- } else {
- without_analysis
- };
- collapse_blank_lines(&formatted).trim().to_string()
- }
- #[must_use]
- pub fn get_compact_continuation_message(
- summary: &str,
- suppress_follow_up_questions: bool,
- recent_messages_preserved: bool,
- ) -> String {
- let mut base = format!(
- "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
- format_compact_summary(summary)
- );
- if recent_messages_preserved {
- base.push_str("\n\nRecent messages are preserved verbatim.");
- }
- if suppress_follow_up_questions {
- base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
- }
- base
- }
- #[must_use]
- pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
- if !should_compact(session, config) {
- return CompactionResult {
- summary: String::new(),
- formatted_summary: String::new(),
- compacted_session: session.clone(),
- removed_message_count: 0,
- };
- }
- let keep_from = session
- .messages
- .len()
- .saturating_sub(config.preserve_recent_messages);
- let removed = &session.messages[..keep_from];
- 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 {
- role: MessageRole::System,
- blocks: vec![ContentBlock::Text { text: continuation }],
- usage: None,
- }];
- compacted_messages.extend(preserved);
- CompactionResult {
- summary,
- formatted_summary,
- compacted_session: Session {
- version: session.version,
- messages: compacted_messages,
- },
- removed_message_count: removed.len(),
- }
- }
- 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()
- .filter(|message| message.role == MessageRole::User)
- .count();
- let assistant_messages = messages
- .iter()
- .filter(|message| message.role == MessageRole::Assistant)
- .count();
- let tool_messages = messages
- .iter()
- .filter(|message| message.role == MessageRole::Tool)
- .count();
- let mut tool_names = messages
- .iter()
- .flat_map(|message| message.blocks.iter())
- .filter_map(|block| match block {
- ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
- ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
- ContentBlock::Text { .. } => None,
- })
- .collect::<Vec<_>>();
- tool_names.sort_unstable();
- tool_names.dedup();
- let mut lines = vec![
- "<summary>".to_string(),
- "Conversation summary:".to_string(),
- format!(
- "- Scope: {} earlier messages compacted (user={}, assistant={}, tool={}).",
- messages.len(),
- user_messages,
- assistant_messages,
- tool_messages
- ),
- ];
- if !tool_names.is_empty() {
- lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
- }
- let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3);
- if !recent_user_requests.is_empty() {
- lines.push("- Recent user requests:".to_string());
- lines.extend(
- recent_user_requests
- .into_iter()
- .map(|request| format!(" - {request}")),
- );
- }
- let pending_work = infer_pending_work(messages);
- if !pending_work.is_empty() {
- lines.push("- Pending work:".to_string());
- lines.extend(pending_work.into_iter().map(|item| format!(" - {item}")));
- }
- let key_files = collect_key_files(messages);
- if !key_files.is_empty() {
- lines.push(format!("- Key files referenced: {}.", key_files.join(", ")));
- }
- if let Some(current_work) = infer_current_work(messages) {
- lines.push(format!("- Current work: {current_work}"));
- }
- lines.push("- Key timeline:".to_string());
- for message in messages {
- let role = match message.role {
- MessageRole::System => "system",
- MessageRole::User => "user",
- MessageRole::Assistant => "assistant",
- MessageRole::Tool => "tool",
- };
- let content = message
- .blocks
- .iter()
- .map(summarize_block)
- .collect::<Vec<_>>()
- .join(" | ");
- lines.push(format!(" - {role}: {content}"));
- }
- lines.push("</summary>".to_string());
- lines.join("\n")
- }
- fn summarize_block(block: &ContentBlock) -> String {
- let raw = match block {
- ContentBlock::Text { text } => text.clone(),
- ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
- ContentBlock::ToolResult {
- tool_name,
- output,
- is_error,
- ..
- } => format!(
- "tool_result {tool_name}: {}{output}",
- if *is_error { "error " } else { "" }
- ),
- };
- truncate_summary(&raw, 160)
- }
- fn collect_recent_role_summaries(
- messages: &[ConversationMessage],
- role: MessageRole,
- limit: usize,
- ) -> Vec<String> {
- messages
- .iter()
- .filter(|message| message.role == role)
- .rev()
- .filter_map(|message| first_text_block(message))
- .take(limit)
- .map(|text| truncate_summary(text, 160))
- .collect::<Vec<_>>()
- .into_iter()
- .rev()
- .collect()
- }
- fn infer_pending_work(messages: &[ConversationMessage]) -> Vec<String> {
- messages
- .iter()
- .rev()
- .filter_map(first_text_block)
- .filter(|text| {
- let lowered = text.to_ascii_lowercase();
- lowered.contains("todo")
- || lowered.contains("next")
- || lowered.contains("pending")
- || lowered.contains("follow up")
- || lowered.contains("remaining")
- })
- .take(3)
- .map(|text| truncate_summary(text, 160))
- .collect::<Vec<_>>()
- .into_iter()
- .rev()
- .collect()
- }
- fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
- let mut files = messages
- .iter()
- .flat_map(|message| message.blocks.iter())
- .map(|block| match block {
- ContentBlock::Text { text } => text.as_str(),
- ContentBlock::ToolUse { input, .. } => input.as_str(),
- ContentBlock::ToolResult { output, .. } => output.as_str(),
- })
- .flat_map(extract_file_candidates)
- .collect::<Vec<_>>();
- files.sort();
- files.dedup();
- files.into_iter().take(8).collect()
- }
- fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
- messages
- .iter()
- .rev()
- .filter_map(first_text_block)
- .find(|text| !text.trim().is_empty())
- .map(|text| truncate_summary(text, 200))
- }
- fn first_text_block(message: &ConversationMessage) -> Option<&str> {
- message.blocks.iter().find_map(|block| match block {
- ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
- ContentBlock::ToolUse { .. }
- | ContentBlock::ToolResult { .. }
- | ContentBlock::Text { .. } => None,
- })
- }
- fn has_interesting_extension(candidate: &str) -> bool {
- std::path::Path::new(candidate)
- .extension()
- .and_then(|extension| extension.to_str())
- .is_some_and(|extension| {
- ["rs", "ts", "tsx", "js", "json", "md"]
- .iter()
- .any(|expected| extension.eq_ignore_ascii_case(expected))
- })
- }
- fn extract_file_candidates(content: &str) -> Vec<String> {
- content
- .split_whitespace()
- .filter_map(|token| {
- let candidate = token.trim_matches(|char: char| {
- matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
- });
- if candidate.contains('/') && has_interesting_extension(candidate) {
- Some(candidate.to_string())
- } else {
- None
- }
- })
- .collect()
- }
- fn truncate_summary(content: &str, max_chars: usize) -> String {
- if content.chars().count() <= max_chars {
- return content.to_string();
- }
- let mut truncated = content.chars().take(max_chars).collect::<String>();
- truncated.push('…');
- truncated
- }
- fn estimate_message_tokens(message: &ConversationMessage) -> usize {
- message
- .blocks
- .iter()
- .map(|block| match block {
- ContentBlock::Text { text } => text.len() / 4 + 1,
- ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
- ContentBlock::ToolResult {
- tool_name, output, ..
- } => (tool_name.len() + output.len()) / 4 + 1,
- })
- .sum()
- }
- fn extract_tag_block(content: &str, tag: &str) -> Option<String> {
- let start = format!("<{tag}>");
- let end = format!("</{tag}>");
- let start_index = content.find(&start)? + start.len();
- let end_index = content[start_index..].find(&end)? + start_index;
- Some(content[start_index..end_index].to_string())
- }
- fn strip_tag_block(content: &str, tag: &str) -> String {
- let start = format!("<{tag}>");
- let end = format!("</{tag}>");
- if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) {
- let end_index = end_index_rel + end.len();
- let mut stripped = String::new();
- stripped.push_str(&content[..start_index]);
- stripped.push_str(&content[end_index..]);
- stripped
- } else {
- content.to_string()
- }
- }
- fn collapse_blank_lines(content: &str) -> String {
- let mut result = String::new();
- let mut last_blank = false;
- for line in content.lines() {
- let is_blank = line.trim().is_empty();
- if is_blank && last_blank {
- continue;
- }
- result.push_str(line);
- result.push('\n');
- last_blank = is_blank;
- }
- result
- }
- #[cfg(test)]
- mod tests {
- use super::{
- collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
- 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]
- fn leaves_small_sessions_unchanged() {
- let session = Session {
- version: 1,
- messages: vec![ConversationMessage::user_text("hello")],
- };
- let result = compact_session(&session, CompactionConfig::default());
- assert_eq!(result.removed_message_count, 0);
- assert_eq!(result.compacted_session, session);
- assert!(result.summary.is_empty());
- 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 {
- 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,
- },
- );
- assert_eq!(result.removed_message_count, 2);
- assert_eq!(
- result.compacted_session.messages[0].role,
- MessageRole::System
- );
- assert!(matches!(
- &result.compacted_session.messages[0].blocks[0],
- ContentBlock::Text { text } if text.contains("Summary:")
- ));
- assert!(result.formatted_summary.contains("Scope:"));
- assert!(result.formatted_summary.contains("Key timeline:"));
- assert!(should_compact(
- &session,
- CompactionConfig {
- preserve_recent_messages: 2,
- max_estimated_tokens: 1,
- }
- ));
- assert!(
- estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
- );
- }
- #[test]
- fn truncates_long_blocks_in_summary() {
- let summary = super::summarize_block(&ContentBlock::Text {
- text: "x".repeat(400),
- });
- assert!(summary.ends_with('…'));
- assert!(summary.chars().count() <= 161);
- }
- #[test]
- fn extracts_key_files_from_message_content() {
- let files = collect_key_files(&[ConversationMessage::user_text(
- "Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
- )]);
- assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
- assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
- }
- #[test]
- fn infers_pending_work_from_recent_messages() {
- let pending = infer_pending_work(&[
- ConversationMessage::user_text("done"),
- ConversationMessage::assistant(vec![ContentBlock::Text {
- text: "Next: update tests and follow up on remaining CLI polish.".to_string(),
- }]),
- ]);
- assert_eq!(pending.len(), 1);
- assert!(pending[0].contains("Next: update tests"));
- }
- }
|