|
|
@@ -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<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();
|
|
|
@@ -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"));
|
|
|
+ }
|
|
|
}
|