Skip to main content

lean_ctx/tools/
ctx_compress_memory.rs

1use std::path::Path;
2
3use crate::core::tokens::count_tokens;
4
5pub fn handle(path: &str) -> String {
6    // Read-only-roots choke point (#475): this tool rewrites `path` in place (and
7    // writes a sibling backup), so deny up front inside a read-only root.
8    if let Err(e) = crate::core::pathjail::enforce_writable(Path::new(path)) {
9        return format!("ERROR: {e}");
10    }
11
12    let content = match std::fs::read_to_string(path) {
13        Ok(c) => c,
14        Err(e) => return format!("ERROR: Cannot read {path}: {e}"),
15    };
16
17    let original_tokens = count_tokens(&content);
18
19    let backup_path = build_backup_path(path);
20    if !Path::new(&backup_path).exists()
21        && let Err(e) = std::fs::write(&backup_path, &content)
22    {
23        return format!("ERROR: Cannot create backup {backup_path}: {e}");
24    }
25
26    let compressed = compress_memory_file(&content);
27    let compressed_tokens = count_tokens(&compressed);
28
29    if let Err(e) = std::fs::write(path, &compressed) {
30        return format!("ERROR: Cannot write compressed file: {e}");
31    }
32
33    let saved = original_tokens.saturating_sub(compressed_tokens);
34    let pct = if original_tokens > 0 {
35        (saved as f64 / original_tokens as f64 * 100.0).round() as usize
36    } else {
37        0
38    };
39
40    format!(
41        "Compressed {}: {} → {} tokens ({saved} saved, {pct}%)\n\
42         Backup: {backup_path}",
43        Path::new(path)
44            .file_name()
45            .and_then(|f| f.to_str())
46            .unwrap_or(path),
47        original_tokens,
48        compressed_tokens,
49    )
50}
51
52fn build_backup_path(path: &str) -> String {
53    let p = Path::new(path);
54    let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
55    let parent = p.parent().unwrap_or_else(|| Path::new("."));
56    parent
57        .join(format!("{stem}.original.md"))
58        .to_string_lossy()
59        .to_string()
60}
61
62fn compress_memory_file(content: &str) -> String {
63    let mut output = Vec::new();
64    let mut in_code_block = false;
65    let mut code_fence = String::new();
66
67    for line in content.lines() {
68        let trimmed = line.trim();
69
70        if !in_code_block && is_code_fence_start(trimmed) {
71            in_code_block = true;
72            code_fence = trimmed
73                .chars()
74                .take_while(|c| *c == '`' || *c == '~')
75                .collect();
76            output.push(line.to_string());
77            continue;
78        }
79
80        if in_code_block {
81            output.push(line.to_string());
82            if trimmed.starts_with(&code_fence) && trimmed.len() <= code_fence.len() + 1 {
83                in_code_block = false;
84                code_fence.clear();
85            }
86            continue;
87        }
88
89        if is_protected_line(trimmed) {
90            output.push(line.to_string());
91            continue;
92        }
93
94        if trimmed.is_empty() {
95            if output.last().is_some_and(|l| l.trim().is_empty()) {
96                continue;
97            }
98            output.push(String::new());
99            continue;
100        }
101
102        let compressed = compress_prose_line(line);
103        if !compressed.trim().is_empty() {
104            output.push(compressed);
105        }
106    }
107
108    output.join("\n")
109}
110
111fn is_code_fence_start(line: &str) -> bool {
112    line.starts_with("```") || line.starts_with("~~~")
113}
114
115fn is_protected_line(line: &str) -> bool {
116    if line.starts_with('#') {
117        return true;
118    }
119    if line.starts_with("- ") || line.starts_with("* ") || line.starts_with("> ") {
120        return true;
121    }
122    if line.starts_with('|') {
123        return true;
124    }
125    if contains_url_or_path(line) && line.split_whitespace().count() <= 3 {
126        return true;
127    }
128    false
129}
130
131fn contains_url_or_path(line: &str) -> bool {
132    line.contains("http://")
133        || line.contains("https://")
134        || line.contains("ftp://")
135        || (line.contains('/') && line.contains('.') && !line.contains(' '))
136}
137
138fn compress_prose_line(line: &str) -> String {
139    let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
140    let trimmed = line.trim();
141
142    let mut words: Vec<&str> = trimmed.split_whitespace().collect();
143
144    words.retain(|w| !is_filler_word(w));
145
146    let mut result: Vec<String> = Vec::new();
147    let mut i = 0;
148    while i < words.len() {
149        if let Some((replacement, skip)) = try_shorten_phrase(&words, i) {
150            result.push(replacement.to_string());
151            i += skip;
152        } else {
153            result.push(words[i].to_string());
154            i += 1;
155        }
156    }
157
158    format!("{}{}", leading_ws, result.join(" "))
159}
160
161fn is_filler_word(word: &str) -> bool {
162    let w = word.to_lowercase();
163    let w = w.trim_matches(|c: char| c.is_ascii_punctuation());
164    matches!(
165        w,
166        "just" | "really" | "basically" | "actually" | "simply" | "please" | "very" | "quite"
167    )
168}
169
170fn try_shorten_phrase(words: &[&str], pos: usize) -> Option<(&'static str, usize)> {
171    if pos + 2 < words.len() {
172        let three = format!(
173            "{} {} {}",
174            words[pos].to_lowercase(),
175            words[pos + 1].to_lowercase(),
176            words[pos + 2].to_lowercase()
177        );
178        match three.as_str() {
179            "in order to" => return Some(("to", 3)),
180            "as well as" => return Some(("and", 3)),
181            "due to the" => return Some(("because", 3)),
182            "make sure to" => return Some(("ensure", 3)),
183            "a lot of" => return Some(("many", 3)),
184            "on top of" => return Some(("besides", 3)),
185            _ => {}
186        }
187    }
188
189    if pos + 1 < words.len() {
190        let two = format!(
191            "{} {}",
192            words[pos].to_lowercase(),
193            words[pos + 1].to_lowercase()
194        );
195        match two.as_str() {
196            "make sure" => return Some(("ensure", 2)),
197            "a lot" => return Some(("many", 2)),
198            "as well" => return Some(("also", 2)),
199            "in order" => return Some(("to", 2)),
200            "prior to" => return Some(("before", 2)),
201            "due to" => return Some(("because", 2)),
202            _ => {}
203        }
204    }
205
206    None
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn preserves_code_blocks() {
215        let input = "Some text just really here.\n\n```rust\nfn main() {\n    println!(\"hello\");\n}\n```\n\nMore text.";
216        let result = compress_memory_file(input);
217        assert!(result.contains("fn main()"));
218        assert!(result.contains("println!"));
219    }
220
221    #[test]
222    fn preserves_headings() {
223        let input = "# Main Heading\n\nJust some filler text here.\n\n## Sub Heading";
224        let result = compress_memory_file(input);
225        assert!(result.contains("# Main Heading"));
226        assert!(result.contains("## Sub Heading"));
227    }
228
229    #[test]
230    fn preserves_urls() {
231        let input = "Visit https://example.com for details.\nJust some really basic text.";
232        let result = compress_memory_file(input);
233        assert!(result.contains("https://example.com"));
234    }
235
236    #[test]
237    fn removes_filler_words() {
238        let input = "You should just really basically make sure to check this.";
239        let result = compress_prose_line(input);
240        assert!(!result.contains("just"));
241        assert!(!result.contains("really"));
242        assert!(!result.contains("basically"));
243        assert!(result.contains("ensure"));
244    }
245
246    #[test]
247    fn shortens_phrases() {
248        let input = "In order to fix this, make sure to check the config.";
249        let result = compress_prose_line(input);
250        assert!(!result.contains("In order to"));
251        assert!(result.contains("to"));
252        assert!(result.contains("ensure"));
253    }
254
255    #[test]
256    fn collapses_blank_lines() {
257        let input = "Line 1\n\n\n\nLine 2\n\n\nLine 3";
258        let result = compress_memory_file(input);
259        assert!(!result.contains("\n\n\n"));
260    }
261
262    #[test]
263    fn preserves_tables() {
264        let input = "| Col A | Col B |\n|-------|-------|\n| val1  | val2  |";
265        let result = compress_memory_file(input);
266        assert!(result.contains("| Col A | Col B |"));
267    }
268
269    #[test]
270    fn backup_path_computed_correctly() {
271        assert_eq!(
272            Path::new(&build_backup_path("/home/user/.cursorrules")),
273            Path::new("/home/user")
274                .join(".cursorrules.original.md")
275                .as_path()
276        );
277        assert_eq!(
278            Path::new(&build_backup_path("/project/CLAUDE.md")),
279            Path::new("/project").join("CLAUDE.original.md").as_path()
280        );
281    }
282
283    /// #475: in-place memory compaction must refuse to rewrite a file inside a
284    /// read-only root and must not drop a `*.original.md` backup there either.
285    #[cfg(not(feature = "no-jail"))]
286    #[test]
287    fn handle_denies_write_into_read_only_root() {
288        let _iso = crate::core::data_dir::isolated_data_dir();
289        let dir = tempfile::tempdir().unwrap();
290        let ro = dir.path().join("refrepo");
291        std::fs::create_dir_all(&ro).unwrap();
292        let file = ro.join("CLAUDE.md");
293        let original = "# Title\n\nJust some really verbose prose here.\n";
294        std::fs::write(&file, original).unwrap();
295
296        let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
297        crate::test_env::set_var(
298            "LEAN_CTX_READ_ONLY_ROOTS",
299            ro_canon.to_string_lossy().as_ref(),
300        );
301        let out = handle(file.to_string_lossy().as_ref());
302        crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
303
304        assert!(
305            out.starts_with("ERROR") && out.contains("read-only"),
306            "compaction into a read-only root must be refused: {out}"
307        );
308        assert_eq!(
309            std::fs::read_to_string(&file).unwrap(),
310            original,
311            "the file must be untouched"
312        );
313        assert!(
314            !ro.join("CLAUDE.original.md").exists(),
315            "no backup may be written into a read-only root"
316        );
317    }
318}