Skip to main content

vtcode_core/utils/
session_transcript_norm.rs

1//! Transcript normalization helpers for session archives.
2
3use crate::tools::tool_intent::canonical_command_session_tool_name;
4use rustc_hash::FxHashSet;
5use vtcode_commons::formatting::collapse_whitespace as collapse_whitespace_inner;
6
7fn collapse_whitespace(text: &str) -> String {
8    collapse_whitespace_inner(text)
9}
10
11pub fn should_drop_transcript_line(line: &str) -> bool {
12    let trimmed = line.trim();
13    trimmed.starts_with("Latest tool output:")
14        || trimmed.starts_with("Latest user request:")
15        || trimmed.starts_with("Tool output 1:")
16        || trimmed.starts_with("Structured result with fields:")
17        || trimmed.starts_with("Reuse the latest tool outputs already collected in this turn")
18        || trimmed.starts_with("Interrupt received. Stopping task...")
19}
20
21pub fn normalize_recovery_line(line: &str) -> Option<String> {
22    let trimmed = line.trim();
23    if trimmed.starts_with("[!] Turn balancer:")
24        || trimmed.starts_with("[!] Navigation Loop:")
25        || trimmed.starts_with("[!] Navigation loop:")
26    {
27        return Some("Repeated low-signal tool churn triggered recovery.".to_string());
28    }
29
30    if trimmed
31        .contains("I couldn't produce a final synthesis because the model returned no answer on the recovery pass.")
32    {
33        return Some("Recovery pass failed to produce a final synthesis.".to_string());
34    }
35
36    None
37}
38
39pub fn summarize_tool_block(lines: &[String], start: usize) -> (String, usize) {
40    let header = lines[start].trim().to_string();
41    let mut command_continuations = Vec::new();
42    let mut metadata = Vec::new();
43    let mut metadata_seen = FxHashSet::default();
44    let mut index = start + 1;
45
46    while index < lines.len() {
47        let raw = lines[index].trim_end();
48        let trimmed = raw.trim_start();
49        if trimmed.starts_with("• ") || trimmed.starts_with("[!]") || !is_tool_detail_line(trimmed) {
50            break;
51        }
52
53        if let Some(continuation) = trimmed.strip_prefix("│ ") {
54            let continuation = continuation.trim();
55            if !continuation.is_empty() {
56                command_continuations.push(continuation.to_string());
57            }
58        } else if let Some(extra) = summarize_tool_detail(trimmed)
59            && metadata_seen.insert(extra.clone())
60        {
61            metadata.push(extra);
62        }
63
64        index += 1;
65    }
66
67    let mut summary = header;
68    if !command_continuations.is_empty() {
69        summary.push(' ');
70        summary.push_str(&command_continuations.join(" "));
71    }
72    if !metadata.is_empty() {
73        summary.push_str(" [");
74        summary.push_str(&metadata.join(", "));
75        summary.push(']');
76    }
77
78    (collapse_whitespace(&summary), index)
79}
80
81fn is_tool_detail_line(line: &str) -> bool {
82    line.starts_with("│ ")
83        || line.starts_with("└ ")
84        || line.starts_with("✓ ")
85        || line.starts_with("✗ ")
86        || line.starts_with("… +")
87        || line.starts_with("Large output was spooled")
88        || line == "(no output)"
89}
90
91fn summarize_tool_detail(line: &str) -> Option<String> {
92    let path = line
93        .strip_prefix("└ Path:")
94        .map(str::trim)
95        .filter(|value| !value.is_empty())
96        .map(|value| format!("path {value}"));
97    if path.is_some() {
98        return path;
99    }
100
101    let pattern = line
102        .strip_prefix("└ Pattern:")
103        .map(str::trim)
104        .filter(|value| !value.is_empty())
105        .map(|value| format!("pattern {value}"));
106    if pattern.is_some() {
107        return pattern;
108    }
109
110    let filter = line
111        .strip_prefix("└ Filter:")
112        .map(str::trim)
113        .filter(|value| !value.is_empty())
114        .map(|value| format!("filter {value}"));
115    if filter.is_some() {
116        return filter;
117    }
118
119    let glob = line
120        .strip_prefix("└ Glob:")
121        .map(str::trim)
122        .filter(|value| !value.is_empty())
123        .map(|value| format!("glob {value}"));
124    if glob.is_some() {
125        return glob;
126    }
127
128    if let Some(status) = line.strip_prefix("✗ ") {
129        let status = status.trim();
130        if !status.is_empty() {
131            return Some(status.to_string());
132        }
133    }
134
135    None
136}
137
138pub fn normalized_transcript_key(text: &str) -> String {
139    collapse_whitespace(text).to_ascii_lowercase()
140}
141
142pub fn format_repeated_summary(line: &str, repeats: usize) -> String {
143    if repeats <= 1 {
144        return line.to_string();
145    }
146    format!("{line} (repeated x{repeats})")
147}
148
149pub fn push_clean_transcript_line(target: &mut Vec<String>, line: String) {
150    let trimmed = line.trim();
151    if trimmed.is_empty() {
152        if target.last().is_none_or(|last| !last.is_empty()) {
153            target.push(String::new());
154        }
155        return;
156    }
157
158    if target
159        .last()
160        .is_some_and(|last| normalized_transcript_key(last) == normalized_transcript_key(trimmed))
161    {
162        return;
163    }
164
165    target.push(line);
166}
167
168pub fn normalize_session_tool_name(name: &str) -> String {
169    canonical_command_session_tool_name(name).unwrap_or(name).to_string()
170}
171
172pub fn normalize_distinct_tools_for_summary(distinct_tools: &[String]) -> Vec<String> {
173    let mut normalized = Vec::with_capacity(distinct_tools.len());
174    let mut seen = std::collections::BTreeSet::new();
175
176    for tool in distinct_tools {
177        let mapped = normalize_session_tool_name(tool);
178        if seen.insert(mapped.clone()) {
179            normalized.push(mapped);
180        }
181    }
182
183    normalized
184}