use crate::message::{
AssistantResponse, BashOutput, ConversationMessage, MessageKind, TextContent, ToolResultData,
};
use crate::{search, text};
use std::fmt::Write as _;
const RECENT_MESSAGE_COUNT: usize = 30;
const SUMMARY_SEARCH_PAGE_SIZE: usize = 12;
const TOOL_OUTPUT_LINE_LIMIT: usize = 8;
const TOOL_OUTPUT_LINE_MAX_CHARS: usize = 160;
const GOAL_QUERY: &str = "goal objective task purpose working implementing building creating fixing adding update upgrade migrate";
const STATUS_QUERY: &str = "status progress done completed finished resolved fixed todo pending remaining left in progress wip";
const OUTSTANDING_QUERY: &str = "todo pending remaining left wip bug issue fixme";
const DECISION_QUERY: &str =
"decided decision chosen opted settled agreed will use switch change keep stay go with";
const CONSTRAINT_QUERY: &str = "cannot can't must not should not don't do not never constraint restriction limitation requirement preserve avoid";
#[derive(Debug)]
struct ExtractedContext {
goal: String,
outstanding: Vec<String>,
references: Vec<String>,
decisions: Vec<String>,
constraints: Vec<String>,
status: String,
}
pub fn plain_summary(messages: &[ConversationMessage]) -> String {
let ctx = extract_context(messages);
format_summary(&ctx)
}
fn extract_context(messages: &[ConversationMessage]) -> ExtractedContext {
let summary_messages = narrative_messages(messages);
let recent_start = summary_messages.len().saturating_sub(RECENT_MESSAGE_COUNT);
let recent_messages = &summary_messages[recent_start..];
let goal = first_user_text(&summary_messages)
.or_else(|| first_search_line(&summary_messages, GOAL_QUERY))
.unwrap_or_else(|| "Ongoing development work".to_string());
let status = evidence_status(recent_messages).unwrap_or_else(|| {
search_lines(recent_messages, STATUS_QUERY, 1)
.into_iter()
.next()
.map_or_else(
|| "In progress".to_string(),
|status| concise_status(&status),
)
});
ExtractedContext {
goal,
outstanding: outstanding_items(recent_messages, &status),
references: references(&summary_messages),
decisions: search_lines(recent_messages, DECISION_QUERY, 6),
constraints: constraints(&summary_messages),
status,
}
}
fn narrative_messages(messages: &[ConversationMessage]) -> Vec<ConversationMessage> {
messages
.iter()
.filter_map(|message| match &message.kind {
MessageKind::TextContent(text_content) => {
keep_text_message(&message.entry_id, &text_content.role, &text_content.text)
}
MessageKind::AssistantResponse(response) => {
keep_assistant_message(&message.entry_id, response)
}
MessageKind::ToolResultData(tool_result) => {
keep_tool_message(&message.entry_id, tool_result)
}
MessageKind::BashOutput(output) => keep_bash_message(&message.entry_id, output),
})
.collect()
}
fn keep_text_message(entry_id: &str, role: &str, value: &str) -> Option<ConversationMessage> {
let text = text::sanitize(value);
if text.trim().is_empty() {
return None;
}
Some(ConversationMessage {
entry_id: entry_id.to_string(),
kind: MessageKind::TextContent(TextContent {
role: role.to_string(),
text,
}),
})
}
fn keep_assistant_message(
entry_id: &str,
response: &AssistantResponse,
) -> Option<ConversationMessage> {
keep_text_message(entry_id, "assistant", &response.text)
}
fn keep_tool_message(entry_id: &str, tool_result: &ToolResultData) -> Option<ConversationMessage> {
let content = compact_output_text(&tool_result.content);
if content.is_empty() && !tool_result.is_error {
return None;
}
let role = format!("tool/{}", tool_result.tool_name);
let text = if tool_result.is_error {
format!("error: {content}")
} else {
content
};
keep_text_message(entry_id, &role, &text)
}
fn keep_bash_message(entry_id: &str, output: &BashOutput) -> Option<ConversationMessage> {
let mut parts = Vec::new();
if !output.command.trim().is_empty() {
parts.push(format!("$ {}", output.command.trim()));
}
let content = compact_output_text(&output.output);
if !content.is_empty() {
parts.push(content);
}
keep_text_message(entry_id, "bash", &parts.join("\n"))
}
fn compact_output_text(value: &str) -> String {
text::sanitize(value)
.lines()
.filter_map(|line| clean_line(line, TOOL_OUTPUT_LINE_MAX_CHARS))
.take(TOOL_OUTPUT_LINE_LIMIT)
.collect::<Vec<_>>()
.join("\n")
}
fn evidence_status(messages: &[ConversationMessage]) -> Option<String> {
messages.iter().rev().find_map(|message| {
let MessageKind::TextContent(text_content) = &message.kind else {
return None;
};
if !is_evidence_role(&text_content.role) {
return None;
}
let text = text_content.text.to_ascii_lowercase();
if text.contains("test result: ok") || text.contains(" passed") {
return matching_status_line(&text_content.text, &["test result: ok", "passed"])
.map(|line| format!("Recent check passed: {line}"));
}
if text.contains("error") || text.contains("failed") || text.contains("failure") {
return matching_status_line(&text_content.text, &["error", "failed", "failure"])
.map(|line| format!("Needs attention: {line}"));
}
None
})
}
fn is_evidence_role(role: &str) -> bool {
role == "bash" || role.starts_with("tool/")
}
fn matching_status_line(text: &str, needles: &[&str]) -> Option<String> {
text.lines().find_map(|line| {
let lower = line.to_ascii_lowercase();
if needles.iter().any(|needle| lower.contains(needle)) {
clean_line(line, 120)
} else {
None
}
})
}
fn first_user_text(messages: &[ConversationMessage]) -> Option<String> {
messages.iter().find_map(|message| {
let MessageKind::TextContent(text_content) = &message.kind else {
return None;
};
if text_content.role != "user" {
return None;
}
clean_line(&text_content.text, 200)
})
}
fn outstanding_items(messages: &[ConversationMessage], status: &str) -> Vec<String> {
if is_complete_status(status) {
return default_outstanding_items();
}
let items = search_lines(messages, OUTSTANDING_QUERY, 8);
if items.is_empty() {
default_outstanding_items()
} else {
items
}
}
fn constraints(messages: &[ConversationMessage]) -> Vec<String> {
let items = search_lines(messages, CONSTRAINT_QUERY, 6);
if items.is_empty() {
vec!["No specific constraints identified".to_string()]
} else {
items
}
}
fn is_complete_status(status: &str) -> bool {
let status = status.to_ascii_lowercase();
status.contains("all tasks completed")
|| status.contains("all tasks complete")
|| status.contains("no outstanding")
}
fn concise_status(status: &str) -> String {
if is_complete_status(status) {
"All tasks completed".to_string()
} else {
status.to_string()
}
}
fn first_search_line(messages: &[ConversationMessage], query: &str) -> Option<String> {
search_lines(messages, query, 1).into_iter().next()
}
fn search_lines(messages: &[ConversationMessage], query: &str, limit: usize) -> Vec<String> {
if messages.is_empty() || limit == 0 {
return Vec::new();
}
let page_size = SUMMARY_SEARCH_PAGE_SIZE.max(limit * 2);
let query_terms = query_terms(query);
let (hits, _) = search::query(messages, query, 1, page_size);
let mut lines = Vec::new();
for hit in hits {
if let Some(line) = best_content_line(&hit.text, &query_terms)
&& !looks_like_search_context(&line)
&& !is_noise_line(&line)
{
push_unique(&mut lines, line);
}
if lines.len() >= limit {
break;
}
}
lines
}
fn best_content_line(snippet: &str, query_terms: &[String]) -> Option<String> {
snippet
.lines()
.filter_map(|line| clean_line(line, 120))
.find(|line| line_matches_terms(line, query_terms))
}
fn query_terms(query: &str) -> Vec<String> {
text::split_words(query)
.into_iter()
.filter(|word| word.len() > 1 && !text::is_stop_word(word))
.collect()
}
fn line_matches_terms(line: &str, terms: &[String]) -> bool {
let line_words: Vec<String> = text::split_words(line)
.iter()
.map(|word| summary_match_word(word))
.collect();
terms
.iter()
.any(|term| line_words.iter().any(|word| word == term))
}
fn summary_match_word(word: &str) -> String {
word.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '-')
.to_string()
}
fn clean_line(value: &str, max_chars: usize) -> Option<String> {
let line = value
.trim()
.trim_start_matches("- ")
.trim_start_matches("* ");
if line.is_empty() || line.starts_with("...(") {
return None;
}
Some(text::clip(line, max_chars))
}
fn looks_like_search_context(line: &str) -> bool {
let line = line.trim();
line.starts_with("tools:") || line.starts_with("score:")
}
fn is_noise_line(line: &str) -> bool {
let line = line.trim();
let lower = line.to_ascii_lowercase();
lower.starts_with("created #")
|| lower.starts_with("updated #")
|| lower.starts_with("deleted #")
|| lower.starts_with("edited ")
|| lower.contains("line has changed since last read")
|| lower.contains("auto-relocation")
|| lower.starts_with("did you mean")
|| looks_like_hashline_token(line)
|| looks_like_file_list_entry(line)
}
fn looks_like_file_list_entry(line: &str) -> bool {
let Some(token) = line.split_whitespace().next() else {
return false;
};
line.split_whitespace().nth(1).is_none() && is_file_list_token(token)
}
fn references(messages: &[ConversationMessage]) -> Vec<String> {
let mut refs = Vec::new();
for message in messages {
let MessageKind::TextContent(text_content) = &message.kind else {
continue;
};
if is_evidence_role(&text_content.role) {
continue;
}
for token in text_content.text.split_whitespace() {
let token = clean_reference_token(token);
if is_reference_token(&token) && !is_noise_reference_token(&token) {
push_unique(&mut refs, token);
if refs.len() >= 15 {
return refs;
}
}
}
}
refs
}
fn clean_reference_token(token: &str) -> String {
let mut token = token
.trim_matches(|c: char| matches!(c, ',' | ';' | ':' | ')' | '(' | '[' | ']' | '{' | '}'))
.trim_matches(|c: char| matches!(c, '"' | '\'' | '`'))
.trim_end_matches(['.', '?', '*'])
.replace('`', "");
for suffix in ["'s", "’s", "?"] {
if let Some(stripped) = token.strip_suffix(suffix) {
token = stripped.to_string();
}
}
token
}
fn is_reference_token(token: &str) -> bool {
if token.len() < 3 {
return false;
}
token.starts_with("http://")
|| token.starts_with("https://")
|| token.starts_with("../")
|| token.starts_with("./")
|| looks_like_absolute_path(token)
|| looks_like_relative_path(token)
|| (token.starts_with('#') && token[1..].chars().all(|c| c.is_ascii_digit()))
|| is_version_token(token)
|| is_code_file_token(token)
}
fn is_noise_reference_token(token: &str) -> bool {
token.is_empty()
|| token.starts_with("...")
|| token.contains('|')
|| token.chars().all(|ch| ch == '/' || ch == '!')
|| looks_like_hashline_token(token)
|| is_file_list_token(token)
}
fn looks_like_absolute_path(token: &str) -> bool {
token.starts_with('/') && token[1..].contains('/')
}
fn looks_like_relative_path(token: &str) -> bool {
token.contains('/')
&& token
.split('/')
.any(|segment| segment.contains('.') || is_code_file_token(segment))
}
fn looks_like_hashline_token(token: &str) -> bool {
let Some((line_number, rest)) = token.split_once(':') else {
return false;
};
let Some((hash, _)) = rest.split_once('|') else {
return false;
};
!line_number.is_empty()
&& line_number.chars().all(|ch| ch.is_ascii_digit())
&& hash.len() >= 3
&& hash.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn is_file_list_token(token: &str) -> bool {
let token = token.trim_end_matches('/');
if token.is_empty() || token.contains('/') {
return false;
}
token.starts_with('.') || !token.contains('.')
}
fn is_version_token(token: &str) -> bool {
let token = token.strip_prefix('v').unwrap_or(token);
let mut parts = token.split('.');
let (Some(major), Some(minor)) = (parts.next(), parts.next()) else {
return false;
};
major.chars().all(|c| c.is_ascii_digit())
&& minor.chars().all(|c| c.is_ascii_digit())
&& parts.all(|part| {
part.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
fn is_code_file_token(token: &str) -> bool {
let token = token.trim_matches('`');
[".rs", ".ts", ".js", ".json", ".toml", ".md", ".py"]
.iter()
.any(|suffix| token.ends_with(suffix))
}
fn default_outstanding_items() -> Vec<String> {
vec!["No outstanding items identified".to_string()]
}
fn push_unique(items: &mut Vec<String>, item: String) {
if !items.iter().any(|existing| existing == &item) {
items.push(item);
}
}
fn format_summary(ctx: &ExtractedContext) -> String {
let mut sections = Vec::new();
sections.push(format!("## Session Goal\n{}", ctx.goal));
if !ctx.status.is_empty() {
sections.push(format!("## Status\n{}", ctx.status));
}
if !ctx.outstanding.is_empty() {
sections.push(format!(
"## Outstanding Context\n{}",
bullet_list(&ctx.outstanding)
));
}
if !ctx.decisions.is_empty() {
sections.push(format!("## Key Decisions\n{}", bullet_list(&ctx.decisions)));
}
if !ctx.constraints.is_empty() {
sections.push(format!("## Constraints\n{}", bullet_list(&ctx.constraints)));
}
if !ctx.references.is_empty() {
sections.push(format!("## References\n{}", bullet_list(&ctx.references)));
}
sections.join("\n\n")
}
fn bullet_list(items: &[String]) -> String {
let mut out = String::new();
for item in items {
let _ = writeln!(out, "- {item}");
}
out.trim_end().to_string()
}