Skip to main content

lean_ctx/proxy/
compress.rs

1use super::ccr;
2use crate::core::tokens::count_tokens;
3use crate::core::web::distill;
4
5/// Char budget for the research-prose squeeze (~6k tokens). Only oversized prose
6/// is truncated; the squeeze's main job is dedup + blank-collapse, not cutting.
7const RESEARCH_PROSE_CAP: usize = 24_000;
8
9/// Proxy compression funnel: routes a tool result to the right compressor.
10///
11/// 1. Already-cited research output (from `ctx_url_read` / the web layer) is kept
12///    verbatim — it is distilled and citation-stamped, so the shell pipeline must
13///    not touch its footer or claim markers.
14/// 2. Prose results (web fetches, doc reads, research MCP bridges) are squeezed
15///    by the prose-aware research compressor instead of the log/code-tuned shell
16///    engine.
17/// 3. Everything else (shell/build/search output) flows through the unified
18///    `compress_if_beneficial` pipeline. A `$ ...` command hint is extracted so
19///    the pattern engine gets the same routing as the CLI and MCP paths.
20pub fn compress_tool_result(content: &str, tool_name: Option<&str>) -> String {
21    let compressed = compress_inner(content, tool_name);
22    attach_ccr(content, compressed)
23}
24
25/// Make a live-compressed `tool_result` non-lossy (#482): when compression
26/// removed a meaningful amount, tee the verbatim original to the shared
27/// content-addressed store and append a deterministic recovery handle. The
28/// handle is a pure function of the content hash, so the rewritten result is
29/// byte-stable across turns and never invalidates the provider cache prefix
30/// (#448). Passthrough / verbatim results (no real shrink) keep their bytes.
31fn attach_ccr(original: &str, result: String) -> String {
32    if original.len() < ccr::MIN_TEE_BYTES
33        || original.len().saturating_sub(result.len()) < ccr::MIN_TEE_BYTES
34    {
35        return result;
36    }
37    match ccr::persist(original) {
38        Some(handle) => match ccr::inband_locator(&handle) {
39            // In-band (#493): a remote agent can't read the local tee path, so
40            // advertise the echo-able marker instead — echoing it splices the
41            // verbatim original back inline next turn.
42            Some(marker) => format!(
43                "{result}\n[lean-ctx: full original elided to save tokens — echo {marker} \
44                 on your next turn to get the verbatim original spliced back inline]"
45            ),
46            // Shared-filesystem default: the path handle (native read or slice).
47            None => format!(
48                "{result}\n[lean-ctx: full original at {handle} — read it, or \
49                 ctx_expand(id=\"{handle}\", head=N|search=\"…\"|json_path=\"…\") for a slice]"
50            ),
51        },
52        None => result,
53    }
54}
55
56fn compress_inner(content: &str, tool_name: Option<&str>) -> String {
57    if content.trim().is_empty() || content.len() < 200 {
58        return content.to_string();
59    }
60
61    // #479: lean-ctx's own MCP tools already applied their compression policy at
62    // the tool boundary — honouring `raw=true`/`bypass`, `<lc_safe>` spans and
63    // the configured aggressiveness. Their `raw` intent lives in the originating
64    // `tool_use` input, which is invisible here, so re-compressing on the wire
65    // would silently undo an explicit `raw=true` and double-compress everything
66    // else. Pass results from `ctx_*` tools through untouched.
67    if tool_name.is_some_and(is_lean_ctx_tool) {
68        return content.to_string();
69    }
70
71    // #709: honour explicit <lc_safe>…</lc_safe> spans on the proxy path too.
72    // Protected spans pass through verbatim; each unprotected segment flows back
73    // through the normal funnel (markers are stripped, so this never recurses).
74    if crate::core::protect::has_markers(content) {
75        return crate::core::protect::compress_preserving(content, |seg| {
76            compress_inner(seg, tool_name)
77        });
78    }
79
80    if is_cited_research_output(content) {
81        return content.to_string();
82    }
83
84    if extract_command_hint(content).is_none()
85        && looks_like_prose(content)
86        && let Some(out) = squeeze_research_prose(content)
87    {
88        return out;
89    }
90
91    let cmd = infer_command(content, tool_name);
92
93    // Proxy fidelity guard. A foreign shell tool gives us at most a generic
94    // command (`"shell"`) or none, so the engine's command-gated build/test
95    // verbatim guards never fire. When the *output* is unmistakably a build or
96    // test run, preserve it verbatim (bounded by safety-line-preserving
97    // truncation) so compiler errors, panics and test summaries reach the model
98    // intact — the exact signal a bug-fix task depends on.
99    let generic_command = cmd.is_empty() || cmd == "shell";
100    if generic_command
101        && (output_looks_like_test_run(content) || output_looks_like_build_failure(content))
102    {
103        return crate::shell::compress::engine::preserve_verbatim_pub(content);
104    }
105
106    crate::shell::compress::engine::compress_if_beneficial(&cmd, content)
107}
108
109/// Strong, ecosystem-spanning signals that an output is a *test run* (passing or
110/// failing). Conservative — matches the summary/result lines a bug-fix task must
111/// never lose. Only consulted on the proxy path when the real command is unknown.
112fn output_looks_like_test_run(content: &str) -> bool {
113    const NEEDLES: &[&str] = &[
114        "test result:",            // rust
115        "short test summary info", // pytest
116        " passed in ",             // pytest summary
117        " failed in ",             // pytest summary
118        "=== RUN",                 // go
119        "--- FAIL:",               // go
120        "--- PASS:",               // go
121        "Test Suites:",            // jest
122        " examples, ",             // rspec ("5 examples, 0 failures")
123        "FAILED",                  // generic test failure marker
124    ];
125    NEEDLES.iter().any(|n| content.contains(n))
126}
127
128/// Strong, specific signals of a build / compile / runtime failure across the
129/// major toolchains. Used only on the proxy path for generically-named tools so
130/// the failing diagnostics (paths, lines, messages) survive intact.
131fn output_looks_like_build_failure(content: &str) -> bool {
132    const NEEDLES: &[&str] = &[
133        "error[",                            // rustc (E0277 …)
134        ": error:",                          // gcc / clang "file.c:12:5: error:"
135        "fatal error:",                      // gcc / clang
136        "undefined reference to",            // linker
137        "panicked at",                       // rust runtime
138        "could not compile",                 // cargo
139        "Traceback (most recent call last)", // python
140        "AssertionError",                    // python / junit
141        "make: ***",                         // make
142        "Build FAILED",
143        "BUILD FAILED",
144        "Segmentation fault",
145    ];
146    NEEDLES.iter().any(|n| content.contains(n))
147}
148
149/// True when `content` is a lean-ctx web read: distilled body + citation footer
150/// (`Source: …\nSite: … · Retrieved: …`). Such output is re-compression-hostile.
151fn is_cited_research_output(content: &str) -> bool {
152    content.contains("· Retrieved: ") && content.contains("\nSource: ")
153}
154
155/// Code/shell symbols whose density cleanly separates source/logs from prose.
156const CODE_SYMBOLS: &str = "{}<>;=|\\$`";
157
158/// Conservative prose detector: substantial, letter-dense, low code-symbol, with
159/// real sentences and long lines. Code, logs, tables and JSON all fail this.
160fn looks_like_prose(content: &str) -> bool {
161    let sample: String = content.chars().take(4000).collect();
162    let total = sample.chars().count();
163    if total < 600 {
164        return false;
165    }
166    let total_f = total as f32;
167    let alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as f32;
168    let spaces = sample.chars().filter(|c| *c == ' ').count() as f32;
169    let symbols = sample.chars().filter(|c| CODE_SYMBOLS.contains(*c)).count() as f32;
170
171    if alpha / total_f < 0.6 || spaces / total_f < 0.12 || symbols / total_f > 0.06 {
172        return false;
173    }
174    if sample.matches(['.', '!', '?']).count() < 4 {
175        return false;
176    }
177
178    let non_empty: Vec<&str> = sample.lines().filter(|l| !l.trim().is_empty()).collect();
179    if non_empty.is_empty() {
180        return false;
181    }
182    let avg_len =
183        non_empty.iter().map(|l| l.chars().count()).sum::<usize>() as f32 / non_empty.len() as f32;
184    avg_len >= 40.0
185}
186
187/// Apply the prose squeeze, returning a footer-stamped result only when it
188/// actually saves tokens; otherwise `None` so the normal pipeline can try.
189fn squeeze_research_prose(content: &str) -> Option<String> {
190    let before = count_tokens(content);
191    let squeezed = squeeze_research_prose_body(content);
192    if squeezed.trim().is_empty() {
193        return None;
194    }
195    let after = count_tokens(&squeezed);
196    if after + 2 >= before {
197        return None;
198    }
199    Some(crate::core::protocol::append_savings_with_info(
200        &squeezed,
201        before,
202        after,
203        Some("research"),
204        None,
205    ))
206}
207
208/// Choose the prose-squeeze body. Only when the content would actually be
209/// TRUNCATED (over the cap) do we upgrade from FIFO prefix truncation to
210/// extractive centrality ranking — which keeps the most representative sentences
211/// instead of just the first ones — via the cache-safe, memoized wire squeeze
212/// ([`crate::proxy::prose_ranker`]), so the cold→warm engine transition never
213/// changes a frozen-region rewrite (#448/#498). Below the cap the squeeze is a
214/// lossless dedup pass, so the cheaper truncating squeeze is used.
215fn squeeze_research_prose_body(content: &str) -> String {
216    if content.len() > RESEARCH_PROSE_CAP {
217        return super::prose_ranker::squeeze(content, RESEARCH_PROSE_CAP);
218    }
219    distill::squeeze_prose(content, RESEARCH_PROSE_CAP)
220}
221
222/// True when `name` refers to one of lean-ctx's own `ctx_*` MCP tools, whose
223/// results are already compressed at the tool boundary and must not be touched
224/// again by the proxy (#479).
225///
226/// Clients namespace MCP tools differently, so a plain `starts_with("ctx_")`
227/// misses the real-world callers: Claude Code (the reporter's setup) sends
228/// `mcp__lean-ctx__ctx_shell`, others use `lean-ctx:ctx_read`. Strip the client
229/// prefix down to the bare tool segment before matching.
230fn is_lean_ctx_tool(name: &str) -> bool {
231    let bare = name
232        .rsplit("__")
233        .next()
234        .unwrap_or(name)
235        .rsplit([':', '/', '.'])
236        .next()
237        .unwrap_or(name);
238    bare.starts_with("ctx_") || name.starts_with("ctx_")
239}
240
241fn infer_command(content: &str, tool_name: Option<&str>) -> String {
242    if let Some(cmd) = extract_command_hint(content) {
243        return cmd;
244    }
245
246    if let Some(name) = tool_name {
247        let nl = name.to_lowercase();
248        if nl.contains("bash") || nl.contains("shell") || nl.contains("terminal") {
249            return "shell".to_string();
250        }
251        if nl.contains("search") || nl.contains("grep") || nl.contains("find") {
252            return "grep".to_string();
253        }
254    }
255
256    String::new()
257}
258
259fn extract_command_hint(content: &str) -> Option<String> {
260    for line in content.lines().take(3) {
261        let trimmed = line.trim();
262        if let Some(cmd) = trimmed.strip_prefix("$ ") {
263            return Some(cmd.to_string());
264        }
265        if let Some(cmd) = trimmed.strip_prefix("% ") {
266            return Some(cmd.to_string());
267        }
268    }
269    None
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn short_content_unchanged() {
278        let short = "hello world";
279        assert_eq!(compress_tool_result(short, None), short);
280    }
281
282    #[test]
283    fn empty_content_unchanged() {
284        assert_eq!(compress_tool_result("", None), "");
285        assert_eq!(compress_tool_result("   ", None), "   ");
286    }
287
288    #[test]
289    fn command_hint_extraction() {
290        assert_eq!(
291            extract_command_hint("$ cargo build\nCompiling foo"),
292            Some("cargo build".to_string())
293        );
294        assert_eq!(extract_command_hint("no prefix here"), None);
295    }
296
297    #[test]
298    fn tool_name_inference() {
299        assert_eq!(infer_command("some text", Some("bash_execute")), "shell");
300        assert_eq!(infer_command("some text", Some("search_files")), "grep");
301        assert_eq!(infer_command("some text", Some("unknown_tool")), "");
302    }
303
304    #[test]
305    fn lean_ctx_tool_results_pass_through_verbatim() {
306        // A ctx_shell result the tool already produced (raw=true, or its own
307        // compression). The proxy must NOT re-compress / re-truncate it — that
308        // was the #479 defect where `raw=true` was silently undone on the wire.
309        let raw = (1..=120)
310            .map(|i| format!("Line {i:04}: the quick brown fox jumps over the lazy dog"))
311            .collect::<Vec<_>>()
312            .join("\n");
313        assert!(raw.len() > 200);
314        // Bare names AND the namespaced forms real MCP clients emit (Claude Code
315        // `mcp__lean-ctx__ctx_shell`, colon-style `lean-ctx:ctx_read`).
316        for tool in [
317            "ctx_shell",
318            "ctx_read",
319            "ctx_search",
320            "ctx_grep",
321            "mcp__lean-ctx__ctx_shell",
322            "lean-ctx:ctx_read",
323        ] {
324            assert_eq!(
325                compress_tool_result(&raw, Some(tool)),
326                raw,
327                "{tool} output must pass through the proxy verbatim"
328            );
329        }
330        // A foreign tool with identical output is still compressed: the proxy
331        // keeps adding value for non-lean-ctx tools.
332        assert_ne!(
333            compress_tool_result(&raw, Some("bash")),
334            raw,
335            "foreign-tool output should still be compressed by the proxy"
336        );
337    }
338
339    #[test]
340    fn cited_research_output_is_preserved_verbatim() {
341        let cited = format!(
342            "Rust is a language.\n\n---\nSource: Rust — https://x.com/a\n\
343             Site: x.com · Retrieved: 2026-06-06T00:00:00Z\n{}",
344            "Extra body line that would otherwise be touched. ".repeat(20)
345        );
346        assert_eq!(compress_tool_result(&cited, Some("ctx_url_read")), cited);
347    }
348
349    #[test]
350    fn prose_is_squeezed_and_deduped() {
351        let para = "Rust is a multi-paradigm systems programming language that \
352                    emphasizes performance, type safety, and fearless concurrency, \
353                    achieving memory safety without a garbage collector at runtime.";
354        // Repeated paragraph (well over the 600-char prose floor) → dedup keeps one.
355        let input = format!("{}\n", [para; 8].join("\n\n"));
356        assert!(input.len() > 600);
357        let out = compress_tool_result(&input, Some("web_fetch"));
358        assert_eq!(out.matches("fearless concurrency").count(), 1);
359        assert!(out.contains("performance, type safety"));
360    }
361
362    #[test]
363    fn code_output_is_not_treated_as_prose() {
364        let code = "fn main() {\n    let x = vec![1, 2, 3];\n    \
365                    for i in &x { println!(\"{}\", i); }\n}\n"
366            .repeat(20);
367        assert!(!looks_like_prose(&code));
368    }
369
370    #[test]
371    fn shell_log_is_not_treated_as_prose() {
372        let log = "$ cargo build\n   Compiling foo v0.1.0\n    Finished dev\n".repeat(20);
373        assert!(!looks_like_prose(&log));
374    }
375
376    #[test]
377    fn foreign_shell_build_failure_preserved_verbatim() {
378        // A forge/pi-style shell tool: the name says "shell" and the output has
379        // no `$ cmd` hint, so the engine's command-gated guards cannot fire. The
380        // compiler error must still reach the model intact for a bug-fix task.
381        let mut log = String::from("gcc -O2 -c src/versioncmp.c -o versioncmp.o\n");
382        log.push_str("src/versioncmp.c: In function 'version_cmp':\n");
383        log.push_str(
384            "src/versioncmp.c:142:17: error: invalid operands to binary < (have 'char *' and 'int')\n",
385        );
386        for i in 0..40 {
387            log.push_str(&format!("  note: expansion context line {i}\n"));
388        }
389        log.push_str("make: *** [Makefile:23: versioncmp.o] Error 1\n");
390
391        let out = compress_tool_result(&log, Some("shell"));
392        assert!(
393            out.contains("versioncmp.c:142:17: error:"),
394            "compiler error must survive the proxy"
395        );
396        assert!(
397            out.contains("make: ***"),
398            "make failure summary must survive"
399        );
400    }
401
402    #[test]
403    fn foreign_shell_test_failure_preserved_verbatim() {
404        let mut log = String::from("running 3 tests\n");
405        log.push_str("test version::tests::sorts_numeric ... FAILED\n");
406        for i in 0..40 {
407            log.push_str(&format!("note line {i} with some filler content here\n"));
408        }
409        log.push_str("test result: FAILED. 2 passed; 1 failed; 0 ignored\n");
410
411        let out = compress_tool_result(&log, Some("bash"));
412        assert!(
413            out.contains("test result: FAILED"),
414            "test summary must survive the proxy"
415        );
416        assert!(out.contains("sorts_numeric ... FAILED"));
417    }
418
419    #[test]
420    fn plain_shell_log_not_forced_verbatim() {
421        let log = "Listening on port 8080\nRequest received from 10.0.0.2\n".repeat(20);
422        assert!(!output_looks_like_test_run(&log));
423        assert!(!output_looks_like_build_failure(&log));
424    }
425
426    fn big_compressible_log() -> String {
427        (1..=400)
428            .map(|i| format!("[info] processed item {i:04} ok"))
429            .collect::<Vec<_>>()
430            .join("\n")
431    }
432
433    #[test]
434    fn live_compression_is_recoverable_via_ccr_handle() {
435        let _lock = crate::core::data_dir::test_env_lock();
436        let log = big_compressible_log();
437        let out = compress_tool_result(&log, Some("bash"));
438        assert!(
439            out.len() < log.len(),
440            "a large foreign log must be compressed"
441        );
442
443        // The compressed result carries the content-addressed handle, and the
444        // handle points at the *verbatim* original — live compression is now
445        // non-lossy (#482), recoverable with a plain native file read.
446        let handle = ccr::persist(&log).expect("same content -> same handle");
447        assert!(out.contains(&handle), "CCR handle must be embedded: {out}");
448        let recovered = std::fs::read_to_string(&handle).expect("tee file readable");
449        assert!(
450            recovered.contains("processed item 0007 ok")
451                && recovered.contains("processed item 0400 ok"),
452            "verbatim original must be fully recoverable"
453        );
454    }
455
456    #[test]
457    fn live_compression_output_is_byte_stable_across_turns() {
458        let _lock = crate::core::data_dir::test_env_lock();
459        let log = big_compressible_log();
460        let a = compress_tool_result(&log, Some("bash"));
461        let b = compress_tool_result(&log, Some("bash"));
462        assert_eq!(
463            a, b,
464            "the CCR handle is content-addressed, so the rewritten result must be \
465             byte-identical across turns (provider cache prefix stays valid, #448)"
466        );
467    }
468
469    #[test]
470    fn small_or_passthrough_output_gets_no_ccr_handle() {
471        let _lock = crate::core::data_dir::test_env_lock();
472        // Below the 200-char compress floor: passes through, no handle.
473        let tiny = "ok\n".repeat(10);
474        assert!(!compress_tool_result(&tiny, Some("bash")).contains("full original at"));
475        // lean-ctx tool output passes through verbatim (no handle either).
476        let raw = (1..=120)
477            .map(|i| format!("Line {i:04}: lorem ipsum dolor sit amet consectetur"))
478            .collect::<Vec<_>>()
479            .join("\n");
480        let out = compress_tool_result(&raw, Some("ctx_shell"));
481        assert_eq!(out, raw, "lean-ctx tool result must stay verbatim (no CCR)");
482    }
483}