use std::collections::{BTreeSet, HashSet};
use std::time::Instant;
use anyhow::{Result, ensure};
use crate::engine::display;
use crate::engine::message::{ConversationMessage, MessageKind, MessageView, Part};
use crate::engine::model::{Embedder, Embedding, Similarity, TextGen};
use crate::engine::text;
use super::brief::{cap_brief, cap_brief_ranked, conversation_brief, is_internal_tool_name};
use super::types::{
BRIEF_MAX_LINES, COMMIT_LIMIT, COMMIT_SUBJECT_MAX_CHARS, CompactProfiler,
DIRECTIVE_CONTENT_SIMILARITY, DIRECTIVE_PROMPT_MAX_CHARS, DIRECTIVE_SEMANTIC_SIMILARITY,
ExtractedContext, FileActivity, GOAL_LIMIT, OUTSTANDING_LINE_MAX_CHARS, PREFERENCE_LIMIT,
PREFERENCE_LINE_MAX_CHARS, SECTION_CONSTRAINTS, SECTION_CRITICAL_CONTEXT, SECTION_DONE,
SECTION_GOAL, SECTION_IN_PROGRESS, SECTION_PROGRESS, TAG_MODIFIED_FILES, TAG_READ_FILES,
TEXT_LINE_MAX_CHARS,
};
use super::util::{
clean_sentence, compact_summary_text, contains_any, directive_references, entry_ref,
extend_unique, extract_recent, is_compaction_record, load_embedder, non_empty_lines,
push_unique, referenced_sentence, strip_entry_ref,
};
pub(super) fn extract_context(
messages: &[ConversationMessage],
previous_summary: Option<&str>,
embedder: &mut Option<Embedder>,
profiler: &mut CompactProfiler,
) -> Result<ExtractedContext> {
let started = Instant::now();
let mut ctx = ExtractedContext::default();
if let Some(summary) = previous_summary {
merge_prior_summary(summary, &mut ctx);
}
collect_prior_summaries(messages, &mut ctx, previous_summary.is_some());
for message in messages {
collect_goals(message, &mut ctx.goals);
collect_preferences(message, &mut ctx.preferences);
collect_commits(message, &mut ctx.commits);
}
extract_recent(&mut ctx.commits, COMMIT_LIMIT, false);
collect_file_activity(messages, &mut ctx.file_activity);
if ctx.goals.len() >= 2 || ctx.preferences.len() >= 2 {
let embedder = load_embedder(embedder, profiler)?;
let dedup_started = Instant::now();
ctx.deduplicate_directives(embedder)?;
profiler.record("bge directive dedup", dedup_started);
}
extract_recent(&mut ctx.goals, GOAL_LIMIT, true);
extract_recent(&mut ctx.preferences, PREFERENCE_LIMIT, false);
if ctx.goals.len() >= 2 || ctx.preferences.len() >= 2 {
let textgen_started = Instant::now();
let mut textgen = TextGen::load()?;
profiler.record("textgen load", textgen_started);
let refine_started = Instant::now();
if ctx.goals.len() >= 2 && ctx.preferences.len() >= 2 {
refine_directive_groups(&mut ctx.goals, &mut ctx.preferences, &mut textgen)?;
} else {
refine_directives(&mut ctx.goals, "session goals", &mut textgen)?;
refine_directives(&mut ctx.preferences, "user preferences", &mut textgen)?;
}
profiler.record("textgen refine", refine_started);
}
trim_file_activity(&mut ctx.file_activity);
let todos = todo_snapshot(messages);
if !todos.is_empty() {
ctx.outstanding.retain(|item| !item.starts_with("[todo] "));
}
extend_unique(&mut ctx.outstanding, todos);
extend_unique(&mut ctx.outstanding, conversation_outstanding(messages));
let brief_started = Instant::now();
let recent_brief = conversation_brief(messages);
if !recent_brief.is_empty() {
ctx.brief = if ctx.brief.is_empty() {
recent_brief
} else {
format!("{}\n\n{}", ctx.brief, recent_brief)
};
}
if ctx.brief.lines().count() > BRIEF_MAX_LINES {
let references = directive_references(&ctx.goals, &ctx.outstanding);
ctx.brief = if references.is_empty() {
cap_brief(&ctx.brief)
} else {
let embedder = load_embedder(embedder, profiler)?;
let relevance_started = Instant::now();
let brief = cap_brief_ranked(&ctx.brief, &references, embedder)?;
profiler.record("bge brief sampling", relevance_started);
brief
};
}
profiler.record("conversation brief", brief_started);
if ctx.goals.is_empty() {
ctx.goals.push("Ongoing development work".to_string());
}
profiler.record("extract context", started);
Ok(ctx)
}
pub(super) fn collect_prior_summaries(
messages: &[ConversationMessage],
ctx: &mut ExtractedContext,
has_explicit_previous: bool,
) {
let latest_pi_compaction = messages.iter().rposition(|message| {
matches!(&message.kind, MessageKind::PiCompaction { .. })
&& compact_summary_text(message).is_some()
});
for (index, message) in messages.iter().enumerate() {
if matches!(&message.kind, MessageKind::PiCompaction { .. })
&& (has_explicit_previous || Some(index) != latest_pi_compaction)
{
continue;
}
let Some(text) = compact_summary_text(message) else {
continue;
};
merge_prior_summary(&text, ctx);
}
}
pub(super) fn merge_prior_summary(text: &str, ctx: &mut ExtractedContext) {
merge_anchored_summary(text, ctx);
}
pub(super) fn merge_anchored_summary(text: &str, ctx: &mut ExtractedContext) {
let mut section = None;
let mut brief = Vec::new();
for raw_line in text.lines() {
let line = raw_line.trim();
if section == Some(SECTION_CRITICAL_CONTEXT)
&& !matches!(line, TAG_READ_FILES | TAG_MODIFIED_FILES)
{
if !line.is_empty() {
brief.push(raw_line.to_string());
}
continue;
}
match line {
SECTION_GOAL
| SECTION_CONSTRAINTS
| SECTION_DONE
| SECTION_IN_PROGRESS
| SECTION_CRITICAL_CONTEXT
| TAG_READ_FILES
| TAG_MODIFIED_FILES => {
section = Some(line);
continue;
}
SECTION_PROGRESS | "</read-files>" | "</modified-files>" => {
section = None;
continue;
}
_ => {}
}
match section {
Some(SECTION_GOAL) if !line.is_empty() => {
let item = line.strip_prefix("- ").unwrap_or(line);
push_unique(&mut ctx.goals, item.to_string());
}
Some(SECTION_CONSTRAINTS) => {
merge_markdown_item(line, "- ", &mut ctx.preferences);
}
Some(SECTION_DONE) => merge_markdown_item(line, "- [x] ", &mut ctx.commits),
Some(SECTION_IN_PROGRESS) => {
let item = line
.strip_prefix("- [ ] ")
.or_else(|| line.strip_prefix("- "));
if let Some(item) = item {
push_unique(&mut ctx.outstanding, item.to_string());
}
}
Some(TAG_READ_FILES) if !line.is_empty() => {
ctx.file_activity.read.insert(line.to_string());
}
Some(TAG_MODIFIED_FILES) if !line.is_empty() => {
ctx.file_activity.modified.insert(line.to_string());
}
_ => {}
}
}
let prior_brief = brief.join("\n");
if !prior_brief.is_empty() {
ctx.brief = if ctx.brief.is_empty() {
prior_brief
} else {
cap_brief(&format!("{}\n\n{}", ctx.brief, prior_brief))
};
}
}
pub(super) fn merge_markdown_item(line: &str, prefix: &str, items: &mut Vec<String>) {
if let Some(item) = line.strip_prefix(prefix) {
push_unique(items, item.to_string());
}
}
pub(super) fn collect_goals(message: &ConversationMessage, goals: &mut Vec<String>) {
if is_compaction_record(message) || compact_summary_text(message).is_some() {
return;
}
let MessageView::Text { role, text } = message.view() else {
return;
};
if role != "user" {
return;
}
for line in non_empty_lines(&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),
);
}
}
}
pub(super) fn collect_commits(message: &ConversationMessage, commits: &mut Vec<String>) {
if is_compaction_record(message) || compact_summary_text(message).is_some() {
return;
}
let text_value = match message.view() {
MessageView::ToolResult(result) => result.content.as_str(),
MessageView::Bash(output) => output.output.as_str(),
MessageView::Text { .. } | MessageView::Assistant { .. } => return,
};
for line in non_empty_lines(text_value) {
if let Some(commit) = (|| {
let (inside, after) = line.trim().strip_prefix('[')?.split_once(']')?;
let hash = inside.split_whitespace().next_back()?;
if !(7..=40).contains(&hash.len()) || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let subject = after.trim();
let subject = if subject.is_empty() {
line.as_str()
} else {
subject
};
Some(format!(
"{}: {}",
&hash[..hash.len().min(12)],
clean_sentence(subject, COMMIT_SUBJECT_MAX_CHARS)
))
})() {
push_unique(
commits,
format!("{} ({})", commit, entry_ref(&message.entry_id)),
);
}
if commits.len() >= COMMIT_LIMIT {
break;
}
}
}
pub(super) fn collect_preferences(message: &ConversationMessage, preferences: &mut Vec<String>) {
if is_compaction_record(message) || compact_summary_text(message).is_some() {
return;
}
let MessageView::Text { role, text } = message.view() else {
return;
};
if role != "user" {
return;
}
for line in non_empty_lines(&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, PREFERENCE_LINE_MAX_CHARS, &message.entry_id),
);
}
}
}
pub(super) fn collect_file_activity(messages: &[ConversationMessage], activity: &mut FileActivity) {
let errored: HashSet<String> = messages
.iter()
.flat_map(|message| message.parts.iter())
.filter_map(|part| match part {
Part::ToolResult(result) if result.is_error && !result.call_id.is_empty() => {
Some(result.call_id.clone())
}
_ => None,
})
.collect();
let mut seen: HashSet<String> = HashSet::new();
for message in messages {
if !message.is_assistant() {
continue;
}
for tool_call in message.tool_calls() {
if !tool_call.id.is_empty() && errored.contains(tool_call.id.as_str()) {
continue;
}
let Some(path) = display::path_argument(&tool_call.arguments) else {
continue;
};
if is_read_tool(&tool_call.name) {
activity.read.insert(path.clone());
seen.insert(path);
} else if is_create_tool(&tool_call.name) {
if !activity.created.contains(&path) {
if seen.contains(&path) {
activity.modified.insert(path.clone());
} else {
activity.created.insert(path.clone());
}
}
seen.insert(path);
} else if is_write_tool(&tool_call.name) {
if !activity.created.contains(&path) {
activity.modified.insert(path.clone());
}
seen.insert(path);
}
}
}
}
pub(super) fn is_read_tool(name: &str) -> bool {
matches!(name, "read" | "Read" | "read_file" | "View")
}
pub(super) fn is_write_tool(name: &str) -> bool {
matches!(
name,
"edit" | "Edit" | "write" | "Write" | "edit_file" | "write_file" | "MultiEdit"
)
}
pub(super) fn is_create_tool(name: &str) -> bool {
matches!(name, "write" | "Write" | "write_file")
}
pub(super) fn trim_file_activity(activity: &mut FileActivity) {
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.map_paths(&prefix);
activity.modified.map_paths(&prefix);
activity.created.map_paths(&prefix);
}
pub(super) 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("/"))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum DirectivePolarity {
Affirmative,
Negative,
}
#[derive(Debug)]
pub(super) struct Directive {
pub(super) text: String,
pub(super) words: Vec<String>,
pub(super) polarity: DirectivePolarity,
}
impl From<&str> for Directive {
fn from(value: &str) -> Self {
let text = strip_entry_ref(value).to_string();
let words = text::split_words(&text);
let normalized = text.replace('’', "'");
let contracted = normalized
.split(|ch: char| !(ch.is_alphanumeric() || ch == '\''))
.any(|word| word.ends_with("n't"));
let negative_action = words
.iter()
.find(|word| {
!matches!(
word.as_str(),
"all"
| "always"
| "cannot"
| "cant"
| "couldnt"
| "didnt"
| "do"
| "doesnt"
| "dont"
| "ever"
| "longer"
| "must"
| "mustnt"
| "never"
| "no"
| "not"
| "one"
| "please"
| "should"
| "shouldnt"
| "the"
| "user"
| "users"
| "we"
| "wont"
| "wouldnt"
| "you"
)
})
.is_some_and(|word| {
matches!(
word.as_str(),
"avoid"
| "avoids"
| "avoiding"
| "cease"
| "ceases"
| "ceasing"
| "disable"
| "disables"
| "disabled"
| "disallow"
| "disallows"
| "disallowed"
| "forbid"
| "forbids"
| "forbidden"
| "prohibit"
| "prohibits"
| "prohibited"
| "refrain"
| "refrains"
| "refraining"
| "stop"
| "stops"
| "stopping"
)
});
let explicit_negative = contracted
|| words.iter().any(|word| {
matches!(
word.as_str(),
"never"
| "no"
| "not"
| "cannot"
| "dont"
| "doesnt"
| "didnt"
| "cant"
| "couldnt"
| "wont"
| "wouldnt"
| "shouldnt"
| "mustnt"
)
});
let negative = explicit_negative != negative_action;
let polarity = if negative {
DirectivePolarity::Negative
} else {
DirectivePolarity::Affirmative
};
Self {
text,
words,
polarity,
}
}
}
impl Directive {
pub(super) fn content_text(&self) -> String {
let content = self
.words
.iter()
.map(String::as_str)
.filter(|word| !matches!(*word, "always" | "never" | "must" | "should" | "do" | "not"))
.collect::<Vec<_>>()
.join(" ");
if content.is_empty() {
self.text.clone()
} else {
content
}
}
pub(super) fn distinctive_tokens(&self) -> BTreeSet<&str> {
self.words
.iter()
.map(|word| word.trim_end_matches('.'))
.map(|word| match word {
"changes" => "change",
"commits" => "commit",
word => word,
})
.filter(|word| {
!text::STOP_WORDS.contains(word) && !matches!(*word, "always" | "never" | "please")
})
.collect()
}
pub(super) fn has_conflicting_tokens(&self, other: &Self) -> bool {
let left = self.distinctive_tokens();
let right = other.distinctive_tokens();
let left_only = left.difference(&right).copied().collect::<Vec<_>>();
let right_only = right.difference(&left).copied().collect::<Vec<_>>();
let substitution = left_only.len() == 1 && right_only.len() == 1;
let has_word = |directive: &Self, expected: &str| {
directive
.words
.iter()
.any(|word| word.trim_end_matches('.') == expected)
};
let push_object_synonyms = substitution
&& (left_only.as_slice() == ["change"] && right_only.as_slice() == ["commit"]
|| left_only.as_slice() == ["commit"] && right_only.as_slice() == ["change"])
&& has_word(self, "push")
&& has_word(other, "push");
let with_without = has_word(self, "with") && has_word(other, "without")
|| has_word(self, "without") && has_word(other, "with");
(substitution && !push_object_synonyms) || with_without
}
}
#[derive(Clone, Copy)]
pub(super) struct EmbeddedDirective<'a> {
pub(super) directive: &'a Directive,
pub(super) semantic: &'a Embedding,
pub(super) content: &'a Embedding,
}
impl EmbeddedDirective<'_> {
pub(super) fn matches(
self,
other: Self,
semantic_threshold: Similarity,
content_threshold: Similarity,
) -> bool {
if self.directive.polarity != other.directive.polarity
|| self.directive.has_conflicting_tokens(other.directive)
{
return false;
}
if self.semantic.similarity(other.semantic) < semantic_threshold {
return false;
}
self.content.similarity(other.content) >= content_threshold
}
}
impl ExtractedContext {
pub(super) fn deduplicate_directives(&mut self, embedder: &Embedder) -> Result<()> {
let semantic_threshold = Similarity::try_from(DIRECTIVE_SEMANTIC_SIMILARITY)?;
let content_threshold = Similarity::try_from(DIRECTIVE_CONTENT_SIMILARITY)?;
for items in [&mut self.goals, &mut self.preferences] {
if items.len() < 2 {
continue;
}
let directives = items
.iter()
.map(|item| Directive::from(item.as_str()))
.collect::<Vec<_>>();
let content_texts = directives
.iter()
.map(Directive::content_text)
.collect::<Vec<_>>();
let inputs = directives
.iter()
.map(|directive| directive.text.as_str())
.chain(content_texts.iter().map(String::as_str))
.collect::<Vec<_>>();
let embeddings = embedder.embed_batch(&inputs)?;
ensure!(
embeddings.len() == inputs.len(),
"directive embedding count differs"
);
let (semantic_embeddings, content_embeddings) = embeddings.split_at(directives.len());
let candidates = directives
.iter()
.zip(semantic_embeddings)
.zip(content_embeddings)
.map(|((directive, semantic), content)| EmbeddedDirective {
directive,
semantic,
content,
})
.collect::<Vec<_>>();
let mut keep = vec![false; candidates.len()];
let mut retained = Vec::with_capacity(candidates.len());
for (index, candidate) in candidates.into_iter().enumerate().rev() {
let mut duplicate = false;
for existing in &retained {
if candidate.matches(*existing, semantic_threshold, content_threshold) {
duplicate = true;
break;
}
}
if !duplicate {
keep[index] = true;
retained.push(candidate);
}
}
*items = std::mem::take(items)
.into_iter()
.zip(keep)
.filter_map(|(item, retain)| retain.then_some(item))
.collect();
}
Ok(())
}
}
pub(super) fn directive_answer_token_budget(len: usize) -> usize {
(len.saturating_mul(4) + 8).max(16)
}
pub(super) fn parse_prefixed_keep_set(answer: &str, prefix: char, len: usize) -> Vec<usize> {
let mut keep: Vec<usize> = answer
.split(|ch: char| !ch.is_ascii_alphanumeric())
.filter(|part| !part.is_empty())
.filter_map(|part| {
let mut chars = part.chars();
let head = chars.next()?.to_ascii_lowercase();
(head == prefix).then_some(chars.as_str())
})
.filter(|digits| !digits.is_empty())
.filter_map(|digits| digits.parse().ok())
.filter(|idx| (1..=len).contains(idx))
.collect();
keep.sort_unstable();
keep.dedup();
keep
}
pub(super) fn apply_keep_set(items: &mut Vec<String>, keep: &[usize]) {
if keep.is_empty() || keep.len() == items.len() {
return;
}
*items = keep
.iter()
.filter_map(|&idx| items.get(idx - 1).cloned())
.collect();
}
pub(super) fn directive_prompt_text(item: &str) -> String {
clean_sentence(strip_entry_ref(item), DIRECTIVE_PROMPT_MAX_CHARS)
}
pub(super) fn refine_directive_groups(
goals: &mut Vec<String>,
preferences: &mut Vec<String>,
textgen: &mut TextGen,
) -> Result<()> {
use std::fmt::Write as _;
let mut numbered = String::new();
let _ = writeln!(numbered, "[Session goals]");
for (idx, item) in goals.iter().enumerate() {
let _ = writeln!(numbered, "g{}. {}", idx + 1, directive_prompt_text(item));
}
let _ = writeln!(numbered, "\n[User preferences]");
for (idx, item) in preferences.iter().enumerate() {
let _ = writeln!(numbered, "p{}. {}", idx + 1, directive_prompt_text(item));
}
let system = "Return only current ids, one per line, no prose. Use gN for goals and pN for preferences. If all ids in a section are current, list them all.";
let answer = textgen.complete(
system,
&numbered,
directive_answer_token_budget(goals.len().saturating_add(preferences.len())),
)?;
let goal_keep = parse_prefixed_keep_set(&answer, 'g', goals.len());
apply_keep_set(goals, &goal_keep);
let pref_keep = parse_prefixed_keep_set(&answer, 'p', preferences.len());
apply_keep_set(preferences, &pref_keep);
Ok(())
}
pub(super) fn refine_directives(
items: &mut Vec<String>,
kind: &str,
textgen: &mut TextGen,
) -> Result<()> {
use std::fmt::Write as _;
if items.len() < 2 {
return Ok(());
}
let mut numbered = String::new();
for (idx, item) in items.iter().enumerate() {
let _ = writeln!(numbered, "{}. {}", idx + 1, directive_prompt_text(item));
}
let system = format!(
"Return only current {kind} ids, one per line, no prose. If all ids are current, list them all."
);
let answer = textgen.complete(
&system,
&numbered,
directive_answer_token_budget(items.len()),
)?;
let keep = parse_keep_set(&answer, items.len());
apply_keep_set(items, &keep);
Ok(())
}
pub(super) fn parse_keep_set(answer: &str, len: usize) -> Vec<usize> {
let mut keep: Vec<usize> = answer
.split(|ch: char| !ch.is_ascii_digit())
.filter(|part| !part.is_empty())
.filter_map(|part| part.parse().ok())
.filter(|idx| (1..=len).contains(idx))
.collect();
keep.sort_unstable();
keep.dedup();
keep
}
pub(super) fn todo_snapshot(messages: &[ConversationMessage]) -> Vec<String> {
let mut snapshot: Vec<(String, String)> = Vec::new();
let mut events: std::collections::BTreeMap<String, (String, String)> =
std::collections::BTreeMap::new();
let mut entry_id = String::new();
for message in messages {
if message.is_assistant() {
for tool_call in message.tool_calls() {
if !is_internal_tool_name(&tool_call.name) {
continue;
}
if let Some(items) = todo_items_from_arguments(&tool_call.arguments) {
snapshot = items;
events.clear();
entry_id.clone_from(&message.entry_id);
}
}
}
if let MessageView::ToolResult(result) = message.view()
&& is_internal_tool_name(&result.tool_name)
{
let mut changed = false;
for line in non_empty_lines(&result.content) {
changed |= apply_todo_event(&line, &mut events);
}
if changed {
entry_id.clone_from(&message.entry_id);
}
}
}
if !events.is_empty() {
snapshot = events.into_values().collect();
}
snapshot
.into_iter()
.filter(|(_, status)| {
!matches!(
status.as_str(),
"completed" | "done" | "cancelled" | "canceled"
)
})
.map(|(title, status)| format!("[todo] {title} ({status}) ({})", entry_ref(&entry_id)))
.collect()
}
pub(super) fn todo_items_from_arguments(
arguments: &serde_json::Value,
) -> Option<Vec<(String, String)>> {
let obj = arguments.as_object()?;
let list = ["todos", "plan"]
.iter()
.find_map(|key| obj.get(*key).and_then(serde_json::Value::as_array))?;
let mut items = Vec::new();
for entry in list {
let entry = entry.as_object()?;
let title = ["content", "step", "text", "title"]
.iter()
.find_map(|key| entry.get(*key).and_then(serde_json::Value::as_str))?;
let status = entry
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("pending");
items.push((title.to_string(), status.to_string()));
}
(!items.is_empty()).then_some(items)
}
pub(super) fn apply_todo_event(
line: &str,
items: &mut std::collections::BTreeMap<String, (String, String)>,
) -> bool {
if let Some(rest) = line.strip_prefix("Created #")
&& let Some((id, rest)) = rest.split_once(':')
{
let rest = rest.trim();
let (title, status) = match rest.rsplit_once(" (") {
Some((title, status)) if status.ends_with(')') => (title, status.trim_end_matches(')')),
_ => (rest, "pending"),
};
items.insert(id.to_string(), (title.to_string(), status.to_string()));
return true;
}
if let Some(rest) = line.strip_prefix("Updated #")
&& let Some((id, transition)) = rest.split_once(" (")
&& let Some((_, to)) = transition.trim_end_matches(')').rsplit_once("-> ")
&& let Some(item) = items.get_mut(id)
{
item.1 = to.trim().to_string();
return true;
}
false
}
pub(super) fn conversation_resolved(messages: &[ConversationMessage]) -> bool {
for message in messages.iter().rev() {
let text_value = match message.view() {
MessageView::ToolResult(result) => result.content.as_str(),
MessageView::Bash(output) => output.output.as_str(),
_ => continue,
};
for line in non_empty_lines(text_value) {
let lower = line.to_ascii_lowercase();
if is_success_line(&lower) {
return true;
}
if is_tool_failure_line(&lower) {
return false;
}
}
}
false
}
pub(super) fn conversation_outstanding(messages: &[ConversationMessage]) -> Vec<String> {
let conversation_resolved = conversation_resolved(messages);
let mut items = Vec::new();
for message in messages.iter().rev() {
if is_compaction_record(message) || compact_summary_text(message).is_some() {
continue;
}
match message.view() {
MessageView::Text { role, text } => {
if role != "user" && role != "assistant" {
continue;
}
collect_outstanding_lines(&text, role, &message.entry_id, &mut items);
}
MessageView::Assistant { text, .. } => {
collect_outstanding_lines(&text, "assistant", &message.entry_id, &mut items);
}
MessageView::ToolResult(result) => {
if result.is_error && !conversation_resolved {
collect_tool_outstanding_lines(&result.content, &message.entry_id, &mut items);
}
}
MessageView::Bash(output) => {
if !conversation_resolved {
collect_tool_outstanding_lines(&output.output, &message.entry_id, &mut items);
}
}
}
if items.len() >= 5 {
break;
}
}
items.reverse();
items
}
pub(super) 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, OUTSTANDING_LINE_MAX_CHARS, entry_id)
)
} else {
referenced_sentence(&line, OUTSTANDING_LINE_MAX_CHARS, entry_id)
};
push_unique(items, item);
break;
}
}
pub(super) 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, OUTSTANDING_LINE_MAX_CHARS, entry_id),
);
break;
}
}
pub(super) fn is_short_or_omitted_line(line: &str) -> bool {
line.len() < 12 || line.starts_with("...")
}
pub(super) 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",
],
)
}
pub(super) 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"])
}
pub(super) 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")
}