1use super::ccr;
2use crate::core::tokens::count_tokens;
3use crate::core::web::distill;
4
5const RESEARCH_PROSE_CAP: usize = 20_000;
9const RESEARCH_PROSE_CAP_ENV: &str = "LEAN_CTX_RESEARCH_PROSE_CAP";
10
11fn research_prose_cap() -> usize {
12 std::env::var(RESEARCH_PROSE_CAP_ENV)
13 .ok()
14 .and_then(|v| v.trim().parse::<usize>().ok())
15 .filter(|cap| *cap > 0)
16 .unwrap_or(RESEARCH_PROSE_CAP)
17}
18
19pub fn compress_tool_result(content: &str, tool_name: Option<&str>) -> String {
31 let compressed = compress_inner(content, tool_name);
32 attach_ccr(content, compressed)
33}
34
35fn attach_ccr(original: &str, result: String) -> String {
42 if original.len() < ccr::MIN_TEE_BYTES
43 || original.len().saturating_sub(result.len()) < ccr::MIN_TEE_BYTES
44 {
45 return result;
46 }
47 match ccr::persist(original) {
48 Some(handle) => match ccr::inband_locator(&handle) {
49 Some(marker) => format!(
53 "{result}\n[lean-ctx: full original elided to save tokens — echo {marker} \
54 on your next turn to get the verbatim original spliced back inline]"
55 ),
56 None => format!(
58 "{result}\n[lean-ctx: full original at {handle} — read it, or \
59 ctx_expand(id=\"{handle}\", head=N|search=\"…\"|json_path=\"…\") for a slice]"
60 ),
61 },
62 None => result,
63 }
64}
65
66fn compress_inner(content: &str, tool_name: Option<&str>) -> String {
67 if content.trim().is_empty() || content.len() < 200 {
68 return content.to_string();
69 }
70
71 if tool_name.is_some_and(is_lean_ctx_tool) {
78 return content.to_string();
79 }
80
81 if crate::core::protect::has_markers(content) {
85 return crate::core::protect::compress_preserving(content, |seg| {
86 compress_inner(seg, tool_name)
87 });
88 }
89
90 if is_cited_research_output(content) {
91 return content.to_string();
92 }
93
94 if extract_command_hint(content).is_none()
95 && looks_like_prose(content)
96 && let Some(out) = squeeze_research_prose(content)
97 {
98 return out;
99 }
100
101 let cmd = infer_command(content, tool_name);
102
103 let generic_command = cmd.is_empty() || cmd == "shell";
110 if generic_command
111 && (output_looks_like_test_run(content) || output_looks_like_build_failure(content))
112 {
113 return crate::shell::compress::engine::preserve_verbatim_pub(content);
114 }
115
116 crate::shell::compress::engine::compress_if_beneficial(&cmd, content)
117}
118
119fn output_looks_like_test_run(content: &str) -> bool {
123 const NEEDLES: &[&str] = &[
124 "test result:", "short test summary info", " passed in ", " failed in ", "=== RUN", "--- FAIL:", "--- PASS:", "Test Suites:", " examples, ", "FAILED", ];
135 NEEDLES.iter().any(|n| content.contains(n))
136}
137
138fn output_looks_like_build_failure(content: &str) -> bool {
142 const NEEDLES: &[&str] = &[
143 "error[", ": error:", "fatal error:", "undefined reference to", "panicked at", "could not compile", "Traceback (most recent call last)", "AssertionError", "make: ***", "Build FAILED",
153 "BUILD FAILED",
154 "Segmentation fault",
155 ];
156 NEEDLES.iter().any(|n| content.contains(n))
157}
158
159fn is_cited_research_output(content: &str) -> bool {
162 content.contains("· Retrieved: ") && content.contains("\nSource: ")
163}
164
165const CODE_SYMBOLS: &str = "{}<>;=|\\$`";
167
168fn looks_like_prose(content: &str) -> bool {
171 let sample: String = content.chars().take(4000).collect();
172 let total = sample.chars().count();
173 if total < 600 {
174 return false;
175 }
176 let total_f = total as f32;
177 let alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as f32;
178 let spaces = sample.chars().filter(|c| *c == ' ').count() as f32;
179 let symbols = sample.chars().filter(|c| CODE_SYMBOLS.contains(*c)).count() as f32;
180
181 if alpha / total_f < 0.6 || spaces / total_f < 0.12 || symbols / total_f > 0.06 {
182 return false;
183 }
184 if sample.matches(['.', '!', '?']).count() < 4 {
185 return false;
186 }
187
188 let non_empty: Vec<&str> = sample.lines().filter(|l| !l.trim().is_empty()).collect();
189 if non_empty.is_empty() {
190 return false;
191 }
192 let avg_len =
193 non_empty.iter().map(|l| l.chars().count()).sum::<usize>() as f32 / non_empty.len() as f32;
194 avg_len >= 40.0
195}
196
197fn squeeze_research_prose(content: &str) -> Option<String> {
200 let before = count_tokens(content);
201 let squeezed = squeeze_research_prose_body(content);
202 if squeezed.trim().is_empty() {
203 return None;
204 }
205 let after = count_tokens(&squeezed);
206 if after + 2 >= before {
207 return None;
208 }
209 Some(crate::core::protocol::append_savings_with_info(
210 &squeezed,
211 before,
212 after,
213 Some("research"),
214 None,
215 ))
216}
217
218fn squeeze_research_prose_body(content: &str) -> String {
226 let cap = research_prose_cap();
227 if content.len() > cap {
228 return super::prose_ranker::squeeze(content, cap);
229 }
230 distill::squeeze_prose(content, cap)
231}
232
233fn is_lean_ctx_tool(name: &str) -> bool {
242 let bare = name
243 .rsplit("__")
244 .next()
245 .unwrap_or(name)
246 .rsplit([':', '/', '.'])
247 .next()
248 .unwrap_or(name);
249 bare.starts_with("ctx_") || name.starts_with("ctx_")
250}
251
252fn infer_command(content: &str, tool_name: Option<&str>) -> String {
253 if let Some(cmd) = extract_command_hint(content) {
254 return cmd;
255 }
256
257 if let Some(name) = tool_name {
258 let nl = name.to_lowercase();
259 if nl.contains("bash") || nl.contains("shell") || nl.contains("terminal") {
260 return "shell".to_string();
261 }
262 if nl.contains("search") || nl.contains("grep") || nl.contains("find") {
263 return "grep".to_string();
264 }
265 }
266
267 String::new()
268}
269
270fn extract_command_hint(content: &str) -> Option<String> {
271 for line in content.lines().take(3) {
272 let trimmed = line.trim();
273 if let Some(cmd) = trimmed.strip_prefix("$ ") {
274 return Some(cmd.to_string());
275 }
276 if let Some(cmd) = trimmed.strip_prefix("% ") {
277 return Some(cmd.to_string());
278 }
279 }
280 None
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use serial_test::serial;
287
288 #[test]
289 fn short_content_unchanged() {
290 let short = "hello world";
291 assert_eq!(compress_tool_result(short, None), short);
292 }
293
294 #[test]
295 fn empty_content_unchanged() {
296 assert_eq!(compress_tool_result("", None), "");
297 assert_eq!(compress_tool_result(" ", None), " ");
298 }
299
300 #[test]
301 fn command_hint_extraction() {
302 assert_eq!(
303 extract_command_hint("$ cargo build\nCompiling foo"),
304 Some("cargo build".to_string())
305 );
306 assert_eq!(extract_command_hint("no prefix here"), None);
307 }
308
309 #[test]
310 fn tool_name_inference() {
311 assert_eq!(infer_command("some text", Some("bash_execute")), "shell");
312 assert_eq!(infer_command("some text", Some("search_files")), "grep");
313 assert_eq!(infer_command("some text", Some("unknown_tool")), "");
314 }
315
316 #[test]
317 fn lean_ctx_tool_results_pass_through_verbatim() {
318 let raw = (1..=120)
322 .map(|i| format!("Line {i:04}: the quick brown fox jumps over the lazy dog"))
323 .collect::<Vec<_>>()
324 .join("\n");
325 assert!(raw.len() > 200);
326 for tool in [
329 "ctx_shell",
330 "ctx_read",
331 "ctx_search",
332 "ctx_grep",
333 "mcp__lean-ctx__ctx_shell",
334 "lean-ctx:ctx_read",
335 ] {
336 assert_eq!(
337 compress_tool_result(&raw, Some(tool)),
338 raw,
339 "{tool} output must pass through the proxy verbatim"
340 );
341 }
342 assert_ne!(
345 compress_tool_result(&raw, Some("bash")),
346 raw,
347 "foreign-tool output should still be compressed by the proxy"
348 );
349 }
350
351 #[test]
352 fn cited_research_output_is_preserved_verbatim() {
353 let cited = format!(
354 "Rust is a language.\n\n---\nSource: Rust — https://x.com/a\n\
355 Site: x.com · Retrieved: 2026-06-06T00:00:00Z\n{}",
356 "Extra body line that would otherwise be touched. ".repeat(20)
357 );
358 assert_eq!(compress_tool_result(&cited, Some("ctx_url_read")), cited);
359 }
360
361 #[test]
362 fn prose_is_squeezed_and_deduped() {
363 let para = "Rust is a multi-paradigm systems programming language that \
364 emphasizes performance, type safety, and fearless concurrency, \
365 achieving memory safety without a garbage collector at runtime.";
366 let input = format!("{}\n", [para; 8].join("\n\n"));
368 assert!(input.len() > 600);
369 let out = compress_tool_result(&input, Some("web_fetch"));
370 assert_eq!(out.matches("fearless concurrency").count(), 1);
371 assert!(out.contains("performance, type safety"));
372 }
373
374 #[test]
375 #[serial]
376 fn research_prose_cap_env_overrides_default() {
377 let _lock = crate::core::data_dir::test_env_lock();
378 crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, "1234");
379 assert_eq!(research_prose_cap(), 1234);
380 crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
381 }
382
383 #[test]
384 #[serial]
385 fn research_prose_cap_env_invalid_falls_back() {
386 let _lock = crate::core::data_dir::test_env_lock();
387 for value in ["", "not_a_number", "0"] {
388 crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, value);
389 assert_eq!(research_prose_cap(), RESEARCH_PROSE_CAP);
390 }
391 crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
392 }
393
394 #[test]
395 fn code_output_is_not_treated_as_prose() {
396 let code = "fn main() {\n let x = vec![1, 2, 3];\n \
397 for i in &x { println!(\"{}\", i); }\n}\n"
398 .repeat(20);
399 assert!(!looks_like_prose(&code));
400 }
401
402 #[test]
403 fn shell_log_is_not_treated_as_prose() {
404 let log = "$ cargo build\n Compiling foo v0.1.0\n Finished dev\n".repeat(20);
405 assert!(!looks_like_prose(&log));
406 }
407
408 #[test]
409 fn foreign_shell_build_failure_preserved_verbatim() {
410 let mut log = String::from("gcc -O2 -c src/versioncmp.c -o versioncmp.o\n");
414 log.push_str("src/versioncmp.c: In function 'version_cmp':\n");
415 log.push_str(
416 "src/versioncmp.c:142:17: error: invalid operands to binary < (have 'char *' and 'int')\n",
417 );
418 for i in 0..40 {
419 log.push_str(&format!(" note: expansion context line {i}\n"));
420 }
421 log.push_str("make: *** [Makefile:23: versioncmp.o] Error 1\n");
422
423 let out = compress_tool_result(&log, Some("shell"));
424 assert!(
425 out.contains("versioncmp.c:142:17: error:"),
426 "compiler error must survive the proxy"
427 );
428 assert!(
429 out.contains("make: ***"),
430 "make failure summary must survive"
431 );
432 }
433
434 #[test]
435 fn foreign_shell_test_failure_preserved_verbatim() {
436 let mut log = String::from("running 3 tests\n");
437 log.push_str("test version::tests::sorts_numeric ... FAILED\n");
438 for i in 0..40 {
439 log.push_str(&format!("note line {i} with some filler content here\n"));
440 }
441 log.push_str("test result: FAILED. 2 passed; 1 failed; 0 ignored\n");
442
443 let out = compress_tool_result(&log, Some("bash"));
444 assert!(
445 out.contains("test result: FAILED"),
446 "test summary must survive the proxy"
447 );
448 assert!(out.contains("sorts_numeric ... FAILED"));
449 }
450
451 #[test]
452 fn plain_shell_log_not_forced_verbatim() {
453 let log = "Listening on port 8080\nRequest received from 10.0.0.2\n".repeat(20);
454 assert!(!output_looks_like_test_run(&log));
455 assert!(!output_looks_like_build_failure(&log));
456 }
457
458 fn big_compressible_log() -> String {
459 (1..=400)
460 .map(|i| format!("[info] processed item {i:04} ok"))
461 .collect::<Vec<_>>()
462 .join("\n")
463 }
464
465 #[test]
466 fn live_compression_is_recoverable_via_ccr_handle() {
467 let _lock = crate::core::data_dir::test_env_lock();
468 let log = big_compressible_log();
469 let out = compress_tool_result(&log, Some("bash"));
470 assert!(
471 out.len() < log.len(),
472 "a large foreign log must be compressed"
473 );
474
475 let handle = ccr::persist(&log).expect("same content -> same handle");
479 assert!(out.contains(&handle), "CCR handle must be embedded: {out}");
480 let recovered = std::fs::read_to_string(&handle).expect("tee file readable");
481 assert!(
482 recovered.contains("processed item 0007 ok")
483 && recovered.contains("processed item 0400 ok"),
484 "verbatim original must be fully recoverable"
485 );
486 }
487
488 #[test]
489 fn live_compression_output_is_byte_stable_across_turns() {
490 let _lock = crate::core::data_dir::test_env_lock();
491 let log = big_compressible_log();
492 let a = compress_tool_result(&log, Some("bash"));
493 let b = compress_tool_result(&log, Some("bash"));
494 assert_eq!(
495 a, b,
496 "the CCR handle is content-addressed, so the rewritten result must be \
497 byte-identical across turns (provider cache prefix stays valid, #448)"
498 );
499 }
500
501 #[test]
502 fn small_or_passthrough_output_gets_no_ccr_handle() {
503 let _lock = crate::core::data_dir::test_env_lock();
504 let tiny = "ok\n".repeat(10);
506 assert!(!compress_tool_result(&tiny, Some("bash")).contains("full original at"));
507 let raw = (1..=120)
509 .map(|i| format!("Line {i:04}: lorem ipsum dolor sit amet consectetur"))
510 .collect::<Vec<_>>()
511 .join("\n");
512 let out = compress_tool_result(&raw, Some("ctx_shell"));
513 assert_eq!(out, raw, "lean-ctx tool result must stay verbatim (no CCR)");
514 }
515}