use crate::display;
use crate::message::{BashOutput, ConversationMessage, MessageKind, ToolCall};
use crate::text;
use std::collections::BTreeSet;
use std::fmt::Write as _;
const BRIEF_MAX_LINES: usize = 120;
const TEXT_LINE_MAX_CHARS: usize = 240;
const ASSISTANT_LINE_MAX_CHARS: usize = 200;
const BASH_LINE_MAX_CHARS: usize = 120;
const TOOL_CALLS_PER_TURN: usize = 8;
const SECTION_SESSION_GOAL: &str = "[Session Goal]";
const SECTION_FILES_AND_CHANGES: &str = "[Files And Changes]";
const SECTION_COMMITS: &str = "[Commits]";
const SECTION_OUTSTANDING_CONTEXT: &str = "[Outstanding Context]";
const SECTION_USER_PREFERENCES: &str = "[User Preferences]";
#[derive(Debug, Default)]
struct ExtractedContext {
goals: Vec<String>,
file_activity: FileActivity,
commits: Vec<String>,
outstanding: Vec<String>,
preferences: Vec<String>,
brief: String,
}
#[derive(Debug, Default)]
struct FileActivity {
read: BTreeSet<String>,
modified: BTreeSet<String>,
created: BTreeSet<String>,
}
#[derive(Debug)]
struct BriefSection {
header: &'static str,
lines: Vec<String>,
}
pub fn plain_summary(messages: &[ConversationMessage]) -> String {
let ctx = extract_context(messages);
format_summary(&ctx)
}
fn extract_context(messages: &[ConversationMessage]) -> ExtractedContext {
let mut ctx = ExtractedContext::default();
collect_prior_summaries(messages, &mut ctx);
for message in messages {
collect_goals(message, &mut ctx.goals);
collect_preferences(message, &mut ctx.preferences);
collect_commits(message, &mut ctx.commits);
collect_files(message, &mut ctx.file_activity);
}
trim_file_activity(&mut ctx.file_activity);
ctx.outstanding = outstanding_items(messages);
ctx.brief = brief_transcript(messages);
if ctx.goals.is_empty() {
ctx.goals.push("Ongoing development work".to_string());
}
ctx
}
fn collect_prior_summaries(messages: &[ConversationMessage], ctx: &mut ExtractedContext) {
for message in messages {
if let Some(text) = compact_summary_text(message) {
merge_prior_summary(text, ctx);
}
}
}
fn compact_summary_text(message: &ConversationMessage) -> Option<&str> {
let text = match &message.kind {
MessageKind::TextContent(text_content) => text_content.text.as_str(),
MessageKind::AssistantResponse(response) => response.text.as_str(),
MessageKind::ToolResultData(_) | MessageKind::BashOutput(_) => return None,
};
is_compact_summary(text).then_some(text)
}
fn is_compact_summary(text: &str) -> bool {
text.lines()
.any(|line| matches_compact_section(line.trim()))
}
fn matches_compact_section(line: &str) -> bool {
matches!(
line,
SECTION_SESSION_GOAL
| SECTION_FILES_AND_CHANGES
| SECTION_COMMITS
| SECTION_OUTSTANDING_CONTEXT
| SECTION_USER_PREFERENCES
)
}
fn merge_prior_summary(text: &str, ctx: &mut ExtractedContext) {
let mut section = None;
for line in text.lines().map(str::trim) {
if matches_compact_section(line) {
section = Some(line);
continue;
}
let Some(item) = line.strip_prefix("- ") else {
continue;
};
match section {
Some(SECTION_SESSION_GOAL) => push_unique(&mut ctx.goals, item.to_string()),
Some(SECTION_FILES_AND_CHANGES) => {
merge_prior_file_activity(item, &mut ctx.file_activity);
}
Some(SECTION_COMMITS) => push_unique(&mut ctx.commits, item.to_string()),
Some(SECTION_USER_PREFERENCES) => push_unique(&mut ctx.preferences, item.to_string()),
_ => {}
}
}
}
fn merge_prior_file_activity(item: &str, activity: &mut FileActivity) {
let Some((kind, paths)) = item.split_once(": ") else {
return;
};
for path in paths
.split(',')
.map(str::trim)
.filter(|path| !path.is_empty())
{
let path = path.to_string();
match kind {
"Read" => {
activity.read.insert(path);
}
"Modified" => {
activity.modified.insert(path);
}
"Created" => {
activity.created.insert(path);
}
_ => {}
}
}
}
fn collect_goals(message: &ConversationMessage, goals: &mut Vec<String>) {
let MessageKind::TextContent(text_content) = &message.kind else {
return;
};
if text_content.role != "user" {
return;
}
for line in non_empty_lines(&text_content.text) {
let lower = line.to_ascii_lowercase();
let is_first_goal = goals.is_empty();
let is_scope_change = contains_any(
&lower,
&[
"also ", "instead", "change ", "switch ", "update ", "fix ", "add ", "remove ",
"don't ", "do not ",
],
);
if is_first_goal || is_scope_change {
push_unique(
goals,
referenced_sentence(&line, TEXT_LINE_MAX_CHARS, &message.entry_id),
);
}
if goals.len() >= 8 {
break;
}
}
}
fn collect_commits(message: &ConversationMessage, commits: &mut Vec<String>) {
if compact_summary_text(message).is_some() {
return;
}
let text_value = match &message.kind {
MessageKind::TextContent(text_content) => &text_content.text,
MessageKind::AssistantResponse(response) => &response.text,
MessageKind::ToolResultData(result) => &result.content,
MessageKind::BashOutput(output) => &output.output,
};
for line in non_empty_lines(text_value) {
if let Some(commit) = commit_line(&line) {
push_unique(
commits,
format!("{} ({})", commit, entry_ref(&message.entry_id)),
);
}
if commits.len() >= 8 {
break;
}
}
}
fn commit_line(line: &str) -> Option<String> {
let words: Vec<&str> = line.split_whitespace().collect();
for (idx, word) in words.iter().enumerate() {
let hash = word.trim_matches(|ch: char| !ch.is_ascii_hexdigit());
if !(7..=40).contains(&hash.len()) || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
continue;
}
let lower = line.to_ascii_lowercase();
if !lower.contains("commit") && !line.trim_start().starts_with('[') {
continue;
}
let rest = words.get(idx + 1..).unwrap_or(&[]).join(" ");
let message = if rest.is_empty() { line } else { &rest };
return Some(format!(
"{}: {}",
&hash[..hash.len().min(12)],
clean_sentence(message, 120)
));
}
None
}
fn collect_preferences(message: &ConversationMessage, preferences: &mut Vec<String>) {
let MessageKind::TextContent(text_content) = &message.kind else {
return;
};
if text_content.role != "user" {
return;
}
for line in non_empty_lines(&text_content.text) {
let lower = line.to_ascii_lowercase();
if contains_any(
&lower,
&[
"prefer ", "always ", "never ", "don't ", "do not ", "must ", "should ",
],
) {
push_unique(
preferences,
referenced_sentence(&line, 180, &message.entry_id),
);
}
if preferences.len() >= 15 {
break;
}
}
}
fn collect_files(message: &ConversationMessage, activity: &mut FileActivity) {
let MessageKind::AssistantResponse(response) = &message.kind else {
return;
};
for tool_call in &response.tool_calls {
let Some(path) = display::path_argument(&tool_call.arguments) else {
continue;
};
if is_read_tool(&tool_call.name) {
activity.read.insert(path.clone());
}
if is_write_tool(&tool_call.name) {
activity.modified.insert(path.clone());
}
if is_create_tool(&tool_call.name) {
activity.created.insert(path);
}
}
}
fn is_read_tool(name: &str) -> bool {
matches!(name, "read" | "Read" | "read_file" | "View")
}
fn is_write_tool(name: &str) -> bool {
matches!(
name,
"edit" | "Edit" | "write" | "Write" | "edit_file" | "write_file" | "MultiEdit"
)
}
fn is_create_tool(name: &str) -> bool {
matches!(name, "write" | "Write" | "write_file")
}
fn trim_file_activity(activity: &mut FileActivity) {
for path in activity.modified.clone() {
activity.created.remove(&path);
}
let all: Vec<String> = activity
.read
.iter()
.chain(activity.modified.iter())
.chain(activity.created.iter())
.cloned()
.collect();
let prefix = longest_common_dir_prefix(&all);
if prefix.is_empty() {
return;
}
activity.read = trim_paths(&activity.read, &prefix);
activity.modified = trim_paths(&activity.modified, &prefix);
activity.created = trim_paths(&activity.created, &prefix);
}
fn longest_common_dir_prefix(paths: &[String]) -> String {
let absolute: Vec<&str> = paths
.iter()
.filter_map(|path| path.starts_with('/').then_some(path.as_str()))
.collect();
if absolute.len() < 2 {
return String::new();
}
let split: Vec<Vec<&str>> = absolute
.iter()
.map(|path| path.split('/').collect())
.collect();
let min_len = split.iter().map(Vec::len).min().unwrap_or(0);
let mut idx = 0;
while idx + 1 < min_len {
let segment = split[0][idx];
if !split.iter().all(|parts| parts[idx] == segment) {
break;
}
idx += 1;
}
if idx < 2 {
String::new()
} else {
format!("{}/", split[0][..idx].join("/"))
}
}
fn trim_paths(paths: &BTreeSet<String>, prefix: &str) -> BTreeSet<String> {
paths
.iter()
.map(|path| path.strip_prefix(prefix).unwrap_or(path).to_string())
.collect()
}
fn outstanding_items(messages: &[ConversationMessage]) -> Vec<String> {
let mut items = Vec::new();
let start = messages.len().saturating_sub(30);
for message in &messages[start..] {
if compact_summary_text(message).is_some() {
continue;
}
match &message.kind {
MessageKind::TextContent(text_content) => {
if text_content.role != "user" && text_content.role != "assistant" {
continue;
}
collect_outstanding_lines(
&text_content.text,
&text_content.role,
&message.entry_id,
&mut items,
);
}
MessageKind::AssistantResponse(response) => {
collect_outstanding_lines(
&response.text,
"assistant",
&message.entry_id,
&mut items,
);
}
MessageKind::ToolResultData(result) => {
if result.is_error {
collect_tool_outstanding_lines(&result.content, &message.entry_id, &mut items);
}
}
MessageKind::BashOutput(output) => {
collect_tool_outstanding_lines(&output.output, &message.entry_id, &mut items);
}
}
if items.len() >= 5 {
break;
}
}
items
}
fn collect_outstanding_lines(
text_value: &str,
role: &str,
entry_id: &str,
items: &mut Vec<String>,
) {
for line in non_empty_lines(text_value) {
let lower = line.to_ascii_lowercase();
if is_resolved_line(&lower) {
continue;
}
if !contains_any(
&lower,
&[
"fail",
"failure",
"error",
"broken",
"cannot",
"can't",
"won't work",
"does not work",
"doesn't work",
"blocked",
"blocker",
"not fixed",
"not resolved",
"crash",
"todo",
"pending",
"remaining",
],
) {
continue;
}
if is_success_line(&lower) {
continue;
}
if is_short_or_omitted_line(&line) {
continue;
}
let item = if role == "user" {
format!("[user] {}", referenced_sentence(&line, 150, entry_id))
} else {
referenced_sentence(&line, 150, entry_id)
};
push_unique(items, item);
break;
}
}
fn collect_tool_outstanding_lines(text_value: &str, entry_id: &str, items: &mut Vec<String>) {
for line in non_empty_lines(text_value) {
let lower = line.to_ascii_lowercase();
if !is_tool_failure_line(&lower)
|| is_success_line(&lower)
|| is_short_or_omitted_line(&line)
{
continue;
}
push_unique(items, referenced_sentence(&line, 150, entry_id));
break;
}
}
fn is_short_or_omitted_line(line: &str) -> bool {
line.len() < 12 || line.starts_with("...")
}
fn is_tool_failure_line(line: &str) -> bool {
contains_any(
line,
&[
"error:",
"error ",
"failed",
"failure",
"panic",
"traceback",
"exception",
"command not found",
"no such file",
"permission denied",
],
)
}
fn is_resolved_line(line: &str) -> bool {
contains_any(
line,
&[
"fixed",
"resolved",
"passing",
"passes",
"now works",
"no longer",
"done",
"completed",
],
) && !contains_any(line, &["not fixed", "not resolved", "unresolved"])
}
fn is_success_line(line: &str) -> bool {
line.contains("test result: ok")
|| line.contains(" 0 failed")
|| line.contains("fail=0")
|| line.contains("failed 0")
|| line.contains("error=0")
}
fn brief_transcript(messages: &[ConversationMessage]) -> String {
let mut sections: Vec<BriefSection> = Vec::new();
for message in messages {
match &message.kind {
MessageKind::TextContent(text_content) => {
if is_compact_summary(&text_content.text) {
continue;
}
if text_content.text.trim().is_empty() {
continue;
}
let header = match text_content.role.as_str() {
"user" => "[user]",
"assistant" => "[assistant]",
_ => continue,
};
push_brief(
&mut sections,
header,
format!(
"{} ({})",
clean_sentence(&text_content.text, brief_limit(header)),
entry_ref(&message.entry_id)
),
);
}
MessageKind::AssistantResponse(response) => {
if is_compact_summary(&response.text) {
continue;
}
if !response.text.trim().is_empty() {
push_brief(
&mut sections,
"[assistant]",
format!(
"{} ({})",
clean_sentence(
&strip_self_talk(&response.text),
ASSISTANT_LINE_MAX_CHARS
),
entry_ref(&message.entry_id)
),
);
}
push_tool_calls(&mut sections, &response.tool_calls, &message.entry_id);
}
MessageKind::BashOutput(output) => {
let cmd = compress_bash(output);
if !cmd.is_empty() {
push_brief(
&mut sections,
"[user]",
format!("$ {} ({})", cmd, entry_ref(&message.entry_id)),
);
}
}
MessageKind::ToolResultData(_) => {}
}
}
cap_brief(&stringify_brief(&mut sections))
}
fn push_tool_calls(sections: &mut Vec<BriefSection>, tool_calls: &[ToolCall], entry_id: &str) {
let visible_tool_calls: Vec<&ToolCall> = tool_calls
.iter()
.filter(|tool_call| is_visible_tool_call(tool_call))
.collect();
if visible_tool_calls.is_empty() {
return;
}
let omitted = visible_tool_calls.len().saturating_sub(TOOL_CALLS_PER_TURN);
if omitted > 0 {
push_brief(
sections,
"[assistant]",
format!("* ({omitted} earlier tool-call entries omitted)"),
);
}
for tool_call in visible_tool_calls.into_iter().skip(omitted) {
push_brief(
sections,
"[assistant]",
format!("{} ({})", tool_one_liner(tool_call), entry_ref(entry_id)),
);
}
}
fn is_visible_tool_call(tool_call: &ToolCall) -> bool {
let name = tool_call.name.trim();
!name.is_empty() && !is_internal_tool_name(name)
}
fn is_internal_tool_name(name: &str) -> bool {
[
"todo",
"todowrite",
"task",
"task_status",
"progress",
"update_plan",
]
.iter()
.any(|internal| name.eq_ignore_ascii_case(internal))
}
fn push_brief(sections: &mut Vec<BriefSection>, header: &'static str, line: String) {
if line.trim().is_empty() {
return;
}
if let Some(last) = sections.last_mut()
&& last.header == header
{
last.lines.push(line);
return;
}
sections.push(BriefSection {
header,
lines: vec![line],
});
}
fn stringify_brief(sections: &mut [BriefSection]) -> String {
collapse_repeated_tool_lines(sections);
cap_tool_lines(sections);
let mut out = Vec::new();
for (idx, section) in sections.iter().enumerate() {
if idx > 0 {
let previous = §ions[idx - 1];
let previous_tools = previous.lines.iter().all(|line| line.starts_with("* "));
let current_tools = section.lines.iter().all(|line| line.starts_with("* "));
if !(previous.header == "[assistant]"
&& section.header == "[assistant]"
&& previous_tools
&& current_tools)
{
out.push(String::new());
}
}
out.push(section.header.to_string());
out.extend(section.lines.iter().cloned());
}
out.join("\n")
}
fn collapse_repeated_tool_lines(sections: &mut [BriefSection]) {
for section in sections {
if section.header != "[assistant]" {
continue;
}
let mut out: Vec<String> = Vec::new();
for line in §ion.lines {
if !line.starts_with("* ") {
out.push(line.clone());
continue;
}
if out.last().is_some_and(|last| last == line) {
continue;
}
out.push(line.clone());
}
section.lines = out;
}
}
fn cap_tool_lines(sections: &mut [BriefSection]) {
for section in sections {
if section.header != "[assistant]" {
continue;
}
let tool_indexes: Vec<usize> = section
.lines
.iter()
.enumerate()
.filter_map(|(idx, line)| line.starts_with("* ").then_some(idx))
.collect();
if tool_indexes.len() <= TOOL_CALLS_PER_TURN {
continue;
}
let drop_count = tool_indexes.len() - TOOL_CALLS_PER_TURN;
let drop_set: BTreeSet<usize> = tool_indexes.iter().take(drop_count).copied().collect();
let first_kept = tool_indexes[drop_count];
let mut next = Vec::new();
let mut inserted = false;
for (idx, line) in section.lines.iter().enumerate() {
if drop_set.contains(&idx) {
continue;
}
if !inserted && idx == first_kept {
next.push(format!(
"* ({drop_count} earlier tool-call entries omitted)"
));
inserted = true;
}
next.push(line.clone());
}
section.lines = next;
}
}
fn cap_brief(value: &str) -> String {
let lines: Vec<&str> = value.lines().collect();
if lines.len() <= BRIEF_MAX_LINES {
return value.to_string();
}
let first_header = lines
.iter()
.position(|line| line.starts_with('[') && line.ends_with(']'))
.unwrap_or(0);
let effective_lines = &lines[first_header..];
let omitted = effective_lines.len().saturating_sub(BRIEF_MAX_LINES);
if omitted == 0 {
return effective_lines.join("\n");
}
let kept = &effective_lines[effective_lines.len() - BRIEF_MAX_LINES..];
format!(
"...({omitted} earlier lines omitted)\n\n{}",
kept.join("\n")
)
}
fn tool_one_liner(tool_call: &ToolCall) -> String {
if let Some(path) = display::path_argument(&tool_call.arguments) {
return format!("* {} \"{}\"", tool_call.name, text::clip(&path, 90));
}
if let Some(obj) = tool_call.arguments.as_object() {
if tool_call.name == "bash" || tool_call.name == "Bash" {
if let Some(command) = obj.get("command").and_then(|value| value.as_str()) {
return format!("* {} \"{}\"", tool_call.name, compress_command(command));
}
}
for key in ["query", "pattern", "description"] {
if let Some(value) = obj.get(key).and_then(|value| value.as_str()) {
return format!("* {} \"{}\"", tool_call.name, text::clip(value, 60));
}
}
}
format!("* {}", tool_call.name)
}
fn compress_bash(output: &BashOutput) -> String {
compress_command(&output.command)
}
fn compress_command(command: &str) -> String {
let mut cmd = command
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(command)
.to_string();
if let Some(stripped) = strip_cd_prefix(&cmd) {
cmd = stripped.to_string();
}
for pipe in [
" | head",
" | tail",
" | sort",
" | wc",
" | column",
" | tr",
" | cut",
] {
if let Some(idx) = cmd.rfind(pipe) {
cmd.truncate(idx);
}
}
text::clip(&cmd, BASH_LINE_MAX_CHARS)
}
fn strip_cd_prefix(command: &str) -> Option<&str> {
let rest = command.strip_prefix("cd ")?;
let (_, suffix) = rest.split_once(" && ")?;
Some(suffix)
}
fn entry_ref(entry_id: &str) -> String {
format!("#{entry_id}")
}
fn brief_limit(header: &str) -> usize {
if header == "[assistant]" {
ASSISTANT_LINE_MAX_CHARS
} else {
TEXT_LINE_MAX_CHARS
}
}
fn strip_self_talk(value: &str) -> String {
let mut text_value = value.trim().to_string();
for _ in 0..2 {
let lower = text_value.to_ascii_lowercase();
let Some(prefix) = ["hmm", "wait", "actually", "oh", "okay", "ok", "well", "so"]
.iter()
.find(|prefix| lower.starts_with(**prefix))
else {
break;
};
let rest = text_value[prefix.len()..].trim_start_matches([',', '.', '!', ' ', '-']);
if rest == text_value {
break;
}
text_value = rest.to_string();
}
text_value
}
fn non_empty_lines(value: &str) -> Vec<String> {
text::sanitize(value)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| !line.starts_with("<skill") && !line.starts_with("</skill"))
.map(ToString::to_string)
.collect()
}
fn clean_sentence(value: &str, max_chars: usize) -> String {
let flat = value.split_whitespace().collect::<Vec<_>>().join(" ");
text::clip(flat.trim_matches(['-', '*', ' ']), max_chars)
}
fn referenced_sentence(value: &str, max_chars: usize, entry_id: &str) -> String {
format!(
"{} ({})",
clean_sentence(value, max_chars),
entry_ref(entry_id)
)
}
fn contains_any(value: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| value.contains(needle))
}
fn push_unique(items: &mut Vec<String>, item: String) {
if !item.is_empty() && !items.iter().any(|existing| existing == &item) {
items.push(item);
}
}
fn format_summary(ctx: &ExtractedContext) -> String {
let mut parts = Vec::new();
push_section(&mut parts, "Session Goal", &ctx.goals);
push_file_activity(&mut parts, &ctx.file_activity);
push_section(&mut parts, "Commits", &ctx.commits);
push_section(&mut parts, "Outstanding Context", &ctx.outstanding);
push_section(&mut parts, "User Preferences", &ctx.preferences);
if !ctx.brief.trim().is_empty() {
parts.push(ctx.brief.clone());
}
if parts.is_empty() {
return String::new();
}
let out = parts.join("\n\n---\n\n");
wrap_long_lines(&out, 120)
}
fn push_section(parts: &mut Vec<String>, title: &str, items: &[String]) {
if items.is_empty() {
return;
}
let mut out = format!("[{title}]\n");
out.push_str(&bullet_list(items));
parts.push(out);
}
fn push_file_activity(parts: &mut Vec<String>, activity: &FileActivity) {
let mut lines = Vec::new();
if !activity.modified.is_empty() {
lines.push(format!("Modified: {}", cap_set(&activity.modified, 10)));
}
if !activity.created.is_empty() {
lines.push(format!("Created: {}", cap_set(&activity.created, 10)));
}
if !activity.read.is_empty() {
lines.push(format!("Read: {}", cap_set(&activity.read, 10)));
}
push_section(parts, "Files And Changes", &lines);
}
fn cap_set(items: &BTreeSet<String>, limit: usize) -> String {
let values: Vec<&String> = items.iter().collect();
let shown = values
.iter()
.take(limit)
.map(|value| value.as_str())
.collect::<Vec<_>>()
.join(", ");
if values.len() > limit {
format!("{shown} (+{} more)", values.len() - limit)
} else {
shown
}
}
fn bullet_list(items: &[String]) -> String {
let mut out = String::new();
for item in items {
let _ = writeln!(out, "- {item}");
}
out.trim_end().to_string()
}
fn wrap_long_lines(value: &str, max_chars: usize) -> String {
value
.lines()
.flat_map(|line| wrap_line(line, max_chars))
.collect::<Vec<_>>()
.join("\n")
}
fn wrap_line(line: &str, max_chars: usize) -> Vec<String> {
if line.len() <= max_chars {
return vec![line.to_string()];
}
let indent_len = line
.chars()
.take_while(|ch| ch.is_whitespace() || *ch == '-' || *ch == '*')
.count()
.min(8);
let continuation = " ".repeat(indent_len);
let mut remaining = line.to_string();
let mut prefix = String::new();
let mut wrapped = Vec::new();
while prefix.len() + remaining.len() > max_chars {
let available = max_chars.saturating_sub(prefix.len()).max(20);
let split_at = remaining[..remaining.len().min(available)]
.rfind(' ')
.filter(|idx| *idx >= available / 2)
.unwrap_or_else(|| text_boundary(&remaining, available));
wrapped.push(format!("{}{}", prefix, remaining[..split_at].trim_end()));
remaining = remaining[split_at..].trim_start().to_string();
prefix.clone_from(&continuation);
}
if !remaining.is_empty() {
wrapped.push(format!("{prefix}{remaining}"));
}
wrapped
}
fn text_boundary(value: &str, pos: usize) -> usize {
let mut pos = pos.min(value.len());
while !value.is_char_boundary(pos) {
pos -= 1;
}
pos
}