use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallSummary {
pub tool_name: String,
pub args_summary: String,
pub result_summary: String,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnSummary {
pub turn_number: usize,
pub user_intent: String,
pub assistant_action: String,
pub tool_calls: Vec<ToolCallSummary>,
pub key_decisions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContextSummary {
pub turns_compacted: usize,
pub turn_summaries: Vec<TurnSummary>,
pub files_read: HashSet<String>,
pub files_written: HashSet<String>,
pub directories_listed: HashSet<String>,
pub key_decisions: Vec<String>,
pub errors_encountered: Vec<String>,
pub tool_usage: HashMap<String, usize>,
}
impl ContextSummary {
pub fn new() -> Self {
Self::default()
}
pub fn add_turn(&mut self, turn: TurnSummary) {
for tc in &turn.tool_calls {
*self.tool_usage.entry(tc.tool_name.clone()).or_insert(0) += 1;
match tc.tool_name.as_str() {
"read_file" => {
self.files_read.insert(tc.args_summary.clone());
}
"write_file" | "write_files" => {
self.files_written.insert(tc.args_summary.clone());
}
"list_directory" => {
self.directories_listed.insert(tc.args_summary.clone());
}
_ => {}
}
if !tc.success && !tc.result_summary.is_empty() {
self.errors_encountered.push(format!(
"{}: {}",
tc.tool_name,
truncate(&tc.result_summary, 100)
));
}
}
self.key_decisions.extend(turn.key_decisions.clone());
self.turn_summaries.push(turn);
self.turns_compacted += 1;
}
pub fn merge(&mut self, other: ContextSummary) {
self.turns_compacted += other.turns_compacted;
self.turn_summaries.extend(other.turn_summaries);
self.files_read.extend(other.files_read);
self.files_written.extend(other.files_written);
self.directories_listed.extend(other.directories_listed);
self.key_decisions.extend(other.key_decisions);
self.errors_encountered.extend(other.errors_encountered);
for (tool, count) in other.tool_usage {
*self.tool_usage.entry(tool).or_insert(0) += count;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryFrame {
pub content: String,
pub token_count: usize,
}
impl SummaryFrame {
pub fn from_summary(summary: &ContextSummary) -> Self {
let mut content = String::new();
content.push_str(&format!(
"<conversation_summary turns=\"{}\">\n",
summary.turns_compacted
));
content.push_str("<overview>\n");
content.push_str(&format!(
"This summary covers {} conversation turn{}.\n",
summary.turns_compacted,
if summary.turns_compacted == 1 {
""
} else {
"s"
}
));
if !summary.tool_usage.is_empty() {
content.push_str("Tools used: ");
let tools: Vec<String> = summary
.tool_usage
.iter()
.map(|(name, count)| format!("{}({}x)", name, count))
.collect();
content.push_str(&tools.join(", "));
content.push('\n');
}
content.push_str("</overview>\n\n");
content.push_str("<turns>\n");
for turn in &summary.turn_summaries {
content.push_str(&format!(
"Turn {}: {} → {}\n",
turn.turn_number,
truncate(&turn.user_intent, 80),
truncate(&turn.assistant_action, 100)
));
let important_tools: Vec<_> = turn
.tool_calls
.iter()
.filter(|tc| {
matches!(
tc.tool_name.as_str(),
"write_file" | "write_files" | "shell" | "analyze_project"
) || !tc.success
})
.collect();
for tc in important_tools.iter().take(3) {
let status = if tc.success { "✓" } else { "✗" };
content.push_str(&format!(
" {} {}({})\n",
status,
tc.tool_name,
truncate(&tc.args_summary, 40)
));
}
if important_tools.len() > 3 {
content.push_str(&format!(
" ... +{} more tool calls\n",
important_tools.len() - 3
));
}
}
content.push_str("</turns>\n\n");
if !summary.files_read.is_empty() || !summary.files_written.is_empty() {
content.push_str("<files_context>\n");
if !summary.files_written.is_empty() {
content.push_str("Files created/modified:\n");
for file in summary.files_written.iter().take(20) {
content.push_str(&format!(" - {}\n", file));
}
if summary.files_written.len() > 20 {
content.push_str(&format!(
" ... +{} more files\n",
summary.files_written.len() - 20
));
}
}
if !summary.files_read.is_empty() {
content.push_str("Files read (content was available):\n");
for file in summary.files_read.iter().take(15) {
content.push_str(&format!(" - {}\n", file));
}
if summary.files_read.len() > 15 {
content.push_str(&format!(
" ... +{} more files\n",
summary.files_read.len() - 15
));
}
}
content.push_str("</files_context>\n\n");
}
if !summary.key_decisions.is_empty() {
content.push_str("<key_decisions>\n");
for decision in summary.key_decisions.iter().take(10) {
content.push_str(&format!("- {}\n", decision));
}
content.push_str("</key_decisions>\n\n");
}
if !summary.errors_encountered.is_empty() {
content.push_str("<errors_encountered>\n");
for error in summary.errors_encountered.iter().take(5) {
content.push_str(&format!("- {}\n", error));
}
content.push_str("</errors_encountered>\n\n");
}
content.push_str("</conversation_summary>");
let token_count = content.len() / 4;
Self {
content,
token_count,
}
}
pub fn minimal(turns: usize, files_written: &[String]) -> Self {
let mut content = format!(
"<conversation_summary turns=\"{}\" minimal=\"true\">\n",
turns
);
if !files_written.is_empty() {
content.push_str("Files created: ");
content.push_str(&files_written.join(", "));
content.push('\n');
}
content.push_str("</conversation_summary>");
let token_count = content.len() / 4;
Self {
content,
token_count,
}
}
}
fn truncate(text: &str, max_len: usize) -> String {
let text = text.trim();
if text.len() <= max_len {
text.to_string()
} else {
format!("{}...", &text[..max_len.saturating_sub(3)])
}
}
pub fn extract_user_intent(message: &str, max_len: usize) -> String {
let message = message.trim();
let cleaned = message
.strip_prefix("please ")
.or_else(|| message.strip_prefix("can you "))
.or_else(|| message.strip_prefix("could you "))
.unwrap_or(message);
truncate(cleaned, max_len)
}
pub fn extract_assistant_action(response: &str, max_len: usize) -> String {
let response = response.trim();
let first_part = response.split(['.', '\n']).next().unwrap_or(response);
truncate(first_part, max_len)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_context_summary() {
let mut summary = ContextSummary::new();
summary.add_turn(TurnSummary {
turn_number: 1,
user_intent: "Analyze the project".to_string(),
assistant_action: "I analyzed the project structure".to_string(),
tool_calls: vec![
ToolCallSummary {
tool_name: "analyze_project".to_string(),
args_summary: ".".to_string(),
result_summary: "Found Rust project".to_string(),
success: true,
},
ToolCallSummary {
tool_name: "read_file".to_string(),
args_summary: "Cargo.toml".to_string(),
result_summary: "Read 50 lines".to_string(),
success: true,
},
],
key_decisions: vec!["This is a Rust CLI project".to_string()],
});
assert_eq!(summary.turns_compacted, 1);
assert!(summary.files_read.contains("Cargo.toml"));
assert_eq!(summary.tool_usage.get("read_file"), Some(&1));
}
#[test]
fn test_summary_frame_generation() {
let mut summary = ContextSummary::new();
summary.files_written.insert("Dockerfile".to_string());
summary.turns_compacted = 3;
let frame = SummaryFrame::from_summary(&summary);
assert!(frame.content.contains("conversation_summary"));
assert!(frame.content.contains("Dockerfile"));
assert!(frame.token_count > 0);
}
#[test]
fn test_extract_user_intent() {
assert_eq!(
extract_user_intent("please analyze the codebase", 50),
"analyze the codebase"
);
assert_eq!(
extract_user_intent("can you create a Dockerfile", 50),
"create a Dockerfile"
);
}
#[test]
fn test_truncate() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("this is a longer text", 10), "this is...");
}
}