lean_ctx/tools/
ctx_compress.rs1use 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 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 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 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
124fn 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 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 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}