diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index 42e63ed..e227019 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -152,6 +152,31 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { 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 { @@ -189,6 +214,106 @@ fn summarize_block(block: &ContentBlock) -> String { 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 } => 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 } 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 { + 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(); @@ -252,8 +377,8 @@ fn collapse_blank_lines(content: &str) -> String { #[cfg(test)] mod tests { use super::{ - compact_session, estimate_session_tokens, format_compact_summary, should_compact, - CompactionConfig, + collect_key_files, compact_session, estimate_session_tokens, format_compact_summary, + infer_pending_work, should_compact, CompactionConfig, }; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; @@ -336,4 +461,25 @@ mod tests { 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")); + } }