mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 04:21:51 +08:00
Preserve actionable state in compacted Rust sessions
This upgrades Rust session compaction so summaries carry more than a flat timeline. The compacted state now calls out recent user requests, pending work signals, key files, and the current work focus so resumed sessions retain stronger execution continuity. The change stays deterministic and local while moving the compact output closer to session-memory style handoff value. Constraint: Keep compaction local and deterministic rather than introducing API-side summarization Constraint: Preserve the existing resumable system-summary mechanism and compact command flow Rejected: Add a full session-memory background extractor now | larger runtime change than needed for this incremental parity pass Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep future compaction enrichments biased toward actionable state transfer, not just verbose recap Tested: cargo fmt; cargo clippy --all-targets --all-features -- -D warnings; cargo test -q Not-tested: Long real-world sessions with deeply nested tool/result payloads
This commit is contained in:
@@ -152,6 +152,31 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
|||||||
lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
|
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());
|
lines.push("- Key timeline:".to_string());
|
||||||
for message in messages {
|
for message in messages {
|
||||||
let role = match message.role {
|
let role = match message.role {
|
||||||
@@ -189,6 +214,106 @@ fn summarize_block(block: &ContentBlock) -> String {
|
|||||||
truncate_summary(&raw, 160)
|
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 {
|
fn truncate_summary(content: &str, max_chars: usize) -> String {
|
||||||
if content.chars().count() <= max_chars {
|
if content.chars().count() <= max_chars {
|
||||||
return content.to_string();
|
return content.to_string();
|
||||||
@@ -252,8 +377,8 @@ fn collapse_blank_lines(content: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
compact_session, estimate_session_tokens, format_compact_summary, should_compact,
|
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||||
CompactionConfig,
|
infer_pending_work, should_compact, CompactionConfig,
|
||||||
};
|
};
|
||||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
|
|
||||||
@@ -336,4 +461,25 @@ mod tests {
|
|||||||
assert!(summary.ends_with('…'));
|
assert!(summary.ends_with('…'));
|
||||||
assert!(summary.chars().count() <= 161);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user