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!("{content}"), &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); 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 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 { .. } | ContentBlock::Thinking { .. } => None, }) .collect::>(); tool_names.sort_unstable(); tool_names.dedup(); let mut lines = vec![ "".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::>() .join(" | "); lines.push(format!(" - {role}: {content}")); } lines.push("".to_string()); lines.join("\n") } fn summarize_block(block: &ContentBlock) -> String { let raw = match block { ContentBlock::Text { text } => text.clone(), ContentBlock::Thinking { text, .. } => format!("thinking: {text}"), 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 { messages .iter() .filter(|message| message.role == role) .rev() .filter_map(|message| first_text_block(message)) .take(limit) .map(|text| truncate_summary(text, 160)) .collect::>() .into_iter() .rev() .collect() } fn infer_pending_work(messages: &[ConversationMessage]) -> Vec { 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::>() .into_iter() .rev() .collect() } fn collect_key_files(messages: &[ConversationMessage]) -> Vec { let mut files = messages .iter() .flat_map(|message| message.blocks.iter()) .map(|block| match block { ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(), ContentBlock::ToolUse { input, .. } => input.as_str(), ContentBlock::ToolResult { output, .. } => output.as_str(), }) .flat_map(extract_file_candidates) .collect::>(); files.sort(); files.dedup(); files.into_iter().take(8).collect() } fn infer_current_work(messages: &[ConversationMessage]) -> Option { 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 } | ContentBlock::Thinking { text, .. } if !text.trim().is_empty() => { Some(text.as_str()) } ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } | ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => 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 { 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::(); truncated.push('…'); truncated } fn estimate_message_tokens(message: &ConversationMessage) -> usize { message .blocks .iter() .map(|block| match block { ContentBlock::Text { text } | ContentBlock::Thinking { 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 { let start = format!("<{tag}>"); let end = format!(""); 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!(""); 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, should_compact, CompactionConfig, }; 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"); } #[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 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")); } }