Skip to main content

lean_ctx/tools/
ctx_compress.rs

1use crate::core::cache::SessionCache;
2use crate::core::protocol;
3use crate::core::signatures;
4use crate::core::tokens::count_tokens;
5use crate::tools::CrpMode;
6use crate::tools::ctx_response;
7
8pub fn handle(cache: &SessionCache, include_signatures: bool, crp_mode: CrpMode) -> String {
9    let entries = cache.get_all_entries();
10    let file_count = entries.len();
11
12    if file_count == 0 {
13        return "CTX CHECKPOINT (0 files)\nNo files cached yet.".to_string();
14    }
15
16    let mut sections = Vec::new();
17    sections.push(format!("CTX CHECKPOINT ({file_count} files)"));
18    // Self-describing outputs (GL #580): one legend up front when the
19    // checkpoint renders TDD symbol notation below.
20    if include_signatures && crp_mode.is_tdd() {
21        sections.push("[λ=fn §=class ∂=trait τ=type ε=enum ν=val +=pub ~=async]".to_string());
22    }
23    sections.push(String::new());
24
25    let mut total_original = 0usize;
26    let refs = cache.file_ref_map();
27
28    for (path, entry) in &entries {
29        total_original += entry.original_tokens;
30        let file_ref = refs.get(*path).map_or("F?", |s| s.as_str());
31        let short = protocol::shorten_path(path);
32
33        if include_signatures {
34            let ext = std::path::Path::new(path)
35                .extension()
36                .and_then(|e| e.to_str())
37                .unwrap_or("");
38            let Some(content) = entry.content() else {
39                continue;
40            };
41            let sigs = signatures::extract_signatures(&content, ext);
42            let sig_names: Vec<String> = sigs
43                .iter()
44                .take(5)
45                .map(|s| {
46                    if crp_mode.is_tdd() {
47                        s.to_tdd()
48                    } else {
49                        s.to_compact()
50                    }
51                })
52                .collect();
53            let more = if sigs.len() > 5 {
54                format!("+{}", sigs.len() - 5)
55            } else {
56                String::new()
57            };
58            sections.push(format!(
59                "{file_ref} {short} [{}L]: {}{more}",
60                entry.line_count,
61                sig_names.join(", "),
62            ));
63        } else {
64            sections.push(format!(
65                "{file_ref} {short} [{}L {}t]",
66                entry.line_count, entry.original_tokens
67            ));
68        }
69    }
70
71    let stats = cache.get_stats();
72    sections.push(String::new());
73    sections.push(format!(
74        "STATS: {} reads, {} hits ({:.0}%)",
75        stats.total_reads(),
76        stats.cache_hits(),
77        stats.hit_rate()
78    ));
79
80    // ACE delta playbook (#541): distill the session into incremental,
81    // stable-ID entries instead of re-summarizing previous summaries —
82    // prevents brevity bias and context collapse across checkpoints.
83    if let Some(playbook_block) = update_playbook_from_session() {
84        sections.push(String::new());
85        sections.push(playbook_block);
86    }
87
88    let contents: Vec<(String, String)> = entries
89        .iter()
90        .filter_map(|(p, e)| Some(((*p).clone(), e.content()?)))
91        .collect();
92    let files_for_codebook: Vec<(&str, &str)> = contents
93        .iter()
94        .map(|(p, c)| (p.as_str(), c.as_str()))
95        .collect();
96    let mut codebook = crate::core::codebook::Codebook::new();
97    codebook.build_from_files(&files_for_codebook);
98
99    let output = sections.join("\n");
100
101    let (final_output, legend) = if codebook.is_empty() {
102        (output, String::new())
103    } else {
104        let (compressed, refs_used) = codebook.compress(&output);
105        let legend = codebook.format_legend(&refs_used);
106        if refs_used.is_empty() {
107            (output, String::new())
108        } else {
109            (compressed, format!("\n{legend}"))
110        }
111    };
112
113    // Apply filler removal to checkpoint output
114    let cleaned_output = ctx_response::handle(&final_output, crp_mode);
115
116    let compressed_tokens = count_tokens(&cleaned_output) + count_tokens(&legend);
117    let savings = protocol::format_savings(total_original, compressed_tokens);
118
119    format!(
120        "{cleaned_output}{legend}\nCOMPRESSION: {total_original} → {compressed_tokens} tok\n{savings}"
121    )
122}
123
124/// Grow-and-refine (#541): fold the latest session findings, decisions and
125/// modified files into the playbook as deltas (dedup confirms instead of
126/// duplicating), evict locally, persist, and return the rendered block.
127fn update_playbook_from_session() -> Option<String> {
128    use crate::core::session::EntryKind;
129
130    let mut session = crate::core::session::SessionState::load_latest()?;
131    let turn = session.stats.total_tool_calls;
132
133    let findings: Vec<String> = session
134        .findings
135        .iter()
136        .rev()
137        .take(8)
138        .map(|f| f.summary.clone())
139        .collect();
140    let decisions: Vec<String> = session
141        .decisions
142        .iter()
143        .rev()
144        .take(5)
145        .map(|d| d.summary.clone())
146        .collect();
147    let modified: Vec<String> = session
148        .files_touched
149        .iter()
150        .filter(|f| f.modified)
151        .rev()
152        .take(10)
153        .map(|f| {
154            let why = f.summary.as_deref().unwrap_or("modified this session");
155            format!("{} — {}", f.path, why)
156        })
157        .collect();
158
159    for s in findings {
160        session.playbook.add_delta(EntryKind::Fact, &s, turn);
161    }
162    for s in decisions {
163        session.playbook.add_delta(EntryKind::Strategy, &s, turn);
164    }
165    for s in modified {
166        session.playbook.add_delta(EntryKind::FileRef, &s, turn);
167    }
168    // Pitfalls from live bounce evidence: extensions that keep bouncing are
169    // exactly the "this bit us" knowledge ACE wants preserved verbatim.
170    if let Ok(bt) = crate::core::bounce_tracker::global().lock()
171        && bt.total_bounces() > 0
172    {
173        for ext_stat in bt.per_extension_json() {
174            let (Some(ext), Some(rate)) = (
175                ext_stat.get("ext").and_then(|v| v.as_str()),
176                ext_stat.get("rate").and_then(serde_json::Value::as_f64),
177            ) else {
178                continue;
179            };
180            if rate >= 0.3 {
181                session.playbook.add_delta(
182                    EntryKind::Pitfall,
183                    &format!(
184                        "{ext} files bounce often ({:.0}% rate) — prefer mode=full",
185                        rate * 100.0
186                    ),
187                    turn,
188                );
189            }
190        }
191    }
192    // Reflector (ACE analog): distill the gotcha trace — proven error→fix
193    // strategies and recurring unresolved pitfalls — into playbook deltas, so
194    // hard-won shell-outcome knowledge survives checkpoints next to live
195    // session findings. Deterministic and dedup-confirmed by `add_delta`.
196    if let Some(root) = session.project_root.as_deref() {
197        let store = crate::core::gotcha_tracker::GotchaStore::load(root);
198        let insights = crate::core::gotcha_tracker::reflect(&store);
199        crate::core::gotcha_tracker::fold_into_playbook(&insights, &mut session.playbook, turn);
200    }
201
202    session.playbook.evict(turn);
203
204    let rendered = session.playbook.render(20);
205    let _ = session.save();
206    if rendered.is_empty() {
207        None
208    } else {
209        Some(rendered)
210    }
211}