lean_ctx/core/
transcript_compact.rs1use std::path::Path;
10
11const MAX_TOOL_OUTPUT_CHARS: usize = 500;
12const MIN_COMPRESS_CHARS: usize = 200;
13
14#[derive(Debug, Default)]
15pub struct CompactionStats {
16 pub lines_processed: usize,
17 pub lines_compacted: usize,
18 pub original_bytes: usize,
19 pub compacted_bytes: usize,
20}
21
22impl CompactionStats {
23 pub fn savings_pct(&self) -> f64 {
24 if self.original_bytes == 0 {
25 return 0.0;
26 }
27 (1.0 - self.compacted_bytes as f64 / self.original_bytes as f64) * 100.0
28 }
29}
30
31impl std::fmt::Display for CompactionStats {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 write!(
34 f,
35 "{} lines ({} compacted), {:.0}% savings ({} → {} bytes)",
36 self.lines_processed,
37 self.lines_compacted,
38 self.savings_pct(),
39 self.original_bytes,
40 self.compacted_bytes,
41 )
42 }
43}
44
45pub fn compact_file(path: &Path) -> Result<CompactionStats, String> {
48 let content = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
49 let mut stats = CompactionStats {
50 original_bytes: content.len(),
51 ..Default::default()
52 };
53
54 let mut output_lines = Vec::new();
55
56 for line in content.lines() {
57 stats.lines_processed += 1;
58
59 if line.len() < MIN_COMPRESS_CHARS || !line.contains("tool_result") {
60 output_lines.push(line.to_string());
61 continue;
62 }
63
64 match compact_jsonl_line(line) {
65 Some(compacted) => {
66 stats.lines_compacted += 1;
67 output_lines.push(compacted);
68 }
69 None => {
70 output_lines.push(line.to_string());
71 }
72 }
73 }
74
75 let result = output_lines.join("\n");
76 stats.compacted_bytes = result.len();
77
78 if stats.lines_compacted > 0 {
79 std::fs::write(path, &result).map_err(|e| format!("write: {e}"))?;
80 }
81
82 Ok(stats)
83}
84
85pub fn compact_directory(dir: &Path) -> Result<CompactionStats, String> {
87 if !dir.is_dir() {
88 return Err(format!("not a directory: {}", dir.display()));
89 }
90
91 let mut total = CompactionStats::default();
92
93 let entries = std::fs::read_dir(dir).map_err(|e| format!("readdir: {e}"))?;
94 for entry in entries.flatten() {
95 let path = entry.path();
96 if path.extension().is_some_and(|e| e == "jsonl") {
97 match compact_file(&path) {
98 Ok(s) => {
99 total.lines_processed += s.lines_processed;
100 total.lines_compacted += s.lines_compacted;
101 total.original_bytes += s.original_bytes;
102 total.compacted_bytes += s.compacted_bytes;
103 }
104 Err(e) => {
105 tracing::warn!("skip {}: {e}", path.display());
106 }
107 }
108 }
109 }
110
111 Ok(total)
112}
113
114fn compact_jsonl_line(line: &str) -> Option<String> {
115 let mut doc: serde_json::Value = serde_json::from_str(line).ok()?;
116
117 let mut modified = false;
118
119 if let Some(content) = doc.get_mut("content") {
120 if let Some(arr) = content.as_array_mut() {
121 for item in arr.iter_mut() {
122 if compact_content_block(item) {
123 modified = true;
124 }
125 }
126 } else if let Some(s) = content.as_str()
127 && s.len() > MAX_TOOL_OUTPUT_CHARS
128 && has_tool_markers(s)
129 {
130 let summary = summarize_content(s);
131 *content = serde_json::Value::String(summary);
132 modified = true;
133 }
134 }
135
136 if let Some(result) = doc.get_mut("result")
137 && compact_content_block(result)
138 {
139 modified = true;
140 }
141
142 if modified {
143 Some(serde_json::to_string(&doc).ok()?)
144 } else {
145 None
146 }
147}
148
149fn compact_content_block(block: &mut serde_json::Value) -> bool {
150 if let Some(text) = block.get_mut("text")
151 && let Some(s) = text.as_str()
152 && s.len() > MAX_TOOL_OUTPUT_CHARS
153 && has_tool_markers(s)
154 {
155 let summary = summarize_content(s);
156 *text = serde_json::Value::String(summary);
157 return true;
158 }
159
160 if let Some(content) = block.get_mut("content") {
161 if let Some(s) = content.as_str()
162 && s.len() > MAX_TOOL_OUTPUT_CHARS
163 {
164 let summary = summarize_content(s);
165 *content = serde_json::Value::String(summary);
166 return true;
167 }
168 if let Some(arr) = content.as_array_mut() {
169 let mut any_modified = false;
170 for item in arr.iter_mut() {
171 if compact_content_block(item) {
172 any_modified = true;
173 }
174 }
175 return any_modified;
176 }
177 }
178
179 false
180}
181
182fn has_tool_markers(s: &str) -> bool {
183 s.contains("tool_result") || s.contains("ctx_") || s.contains("```") || s.len() > 2000
184}
185
186pub(crate) fn summarize_content(text: &str) -> String {
187 let lines: Vec<&str> = text.lines().collect();
188 let total_lines = lines.len();
189 let char_count = text.len();
190
191 let trunc = |s: &str| -> String {
192 if s.len() > 120 {
193 format!("{}...", &s[..s.floor_char_boundary(120)])
194 } else {
195 s.to_string()
196 }
197 };
198
199 let first_meaningful = lines
200 .iter()
201 .take(3)
202 .filter(|l| !l.trim().is_empty())
203 .map(|l| trunc(l))
204 .collect::<Vec<_>>()
205 .join("\n");
206
207 let last_line = lines
208 .iter()
209 .rev()
210 .find(|l| !l.trim().is_empty())
211 .map(|l| trunc(l))
212 .unwrap_or_default();
213
214 format!("[compacted: {total_lines}L, {char_count}ch]\n{first_meaningful}\n...\n{last_line}")
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn summarize_preserves_first_and_last() {
223 let text = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6";
224 let result = summarize_content(text);
225 assert!(result.contains("line 1"));
226 assert!(result.contains("line 6"));
227 assert!(result.contains("[compacted:"));
228 }
229
230 #[test]
231 fn compact_skips_short_lines() {
232 let short = r#"{"type":"text","content":"hello"}"#;
233 assert!(compact_jsonl_line(short).is_none());
234 }
235
236 #[test]
237 fn compact_file_roundtrip() {
238 let dir = tempfile::tempdir().unwrap();
239 let path = dir.path().join("test.jsonl");
240 let line = serde_json::json!({
241 "type": "tool_result",
242 "content": "x".repeat(3000)
243 });
244 std::fs::write(&path, serde_json::to_string(&line).unwrap()).unwrap();
245
246 let stats = compact_file(&path).unwrap();
247 assert_eq!(stats.lines_processed, 1);
248 assert!(stats.compacted_bytes < stats.original_bytes);
249 }
250
251 #[test]
252 fn savings_pct_empty() {
253 let stats = CompactionStats::default();
254 assert_eq!(stats.savings_pct(), 0.0);
255 }
256
257 #[test]
258 fn savings_pct_calculation() {
259 let stats = CompactionStats {
260 original_bytes: 1000,
261 compacted_bytes: 200,
262 ..Default::default()
263 };
264 assert!((stats.savings_pct() - 80.0).abs() < 0.1);
265 }
266}