1use cersei_types::*;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ContextCategory {
10 SystemPrompt,
11 ToolDefinitions,
12 ConversationHistory,
13 ToolResults,
14 Attachments,
15 Unknown,
16}
17
18impl ContextCategory {
19 pub fn label(&self) -> &'static str {
20 match self {
21 Self::SystemPrompt => "System Prompt",
22 Self::ToolDefinitions => "Tool Definitions",
23 Self::ConversationHistory => "Conversation",
24 Self::ToolResults => "Tool Results",
25 Self::Attachments => "Attachments",
26 Self::Unknown => "Other",
27 }
28 }
29}
30
31#[derive(Debug, Clone)]
35pub enum CompactionStrategy {
36 FullCompact { expected_reduction_pct: f64 },
38 PartialCompact {
40 messages_to_compact: usize,
41 expected_reduction_pct: f64,
42 },
43 CollapseReads { expected_reduction_pct: f64 },
45 None,
47}
48
49#[derive(Debug, Clone, Default)]
53pub struct ContextAnalysis {
54 pub system_prompt_tokens: u64,
55 pub tool_definitions_tokens: u64,
56 pub conversation_history_tokens: u64,
57 pub tool_results_tokens: u64,
58 pub attachments_tokens: u64,
59 pub total_tokens: u64,
60 pub compressibility: f64,
62}
63
64impl ContextAnalysis {
65 pub fn category_pct(&self, cat: ContextCategory) -> f64 {
67 if self.total_tokens == 0 {
68 return 0.0;
69 }
70 let tokens = match cat {
71 ContextCategory::SystemPrompt => self.system_prompt_tokens,
72 ContextCategory::ToolDefinitions => self.tool_definitions_tokens,
73 ContextCategory::ConversationHistory => self.conversation_history_tokens,
74 ContextCategory::ToolResults => self.tool_results_tokens,
75 ContextCategory::Attachments => self.attachments_tokens,
76 ContextCategory::Unknown => 0,
77 };
78 tokens as f64 / self.total_tokens as f64
79 }
80}
81
82fn estimate_tokens(text: &str) -> u64 {
85 (text.len() as u64) / 4
86}
87
88pub fn analyze_context(
90 system_prompt: Option<&str>,
91 tool_defs_json: Option<&str>,
92 messages: &[Message],
93) -> ContextAnalysis {
94 let system_prompt_tokens = system_prompt.map(estimate_tokens).unwrap_or(0);
95 let tool_definitions_tokens = tool_defs_json.map(estimate_tokens).unwrap_or(0);
96
97 let mut conversation_tokens: u64 = 0;
98 let mut tool_result_tokens: u64 = 0;
99
100 for msg in messages {
101 match &msg.content {
102 MessageContent::Text(t) => {
103 conversation_tokens += estimate_tokens(t);
104 }
105 MessageContent::Blocks(blocks) => {
106 for block in blocks {
107 match block {
108 ContentBlock::ToolResult { content, .. } => {
109 let text = match content {
110 ToolResultContent::Text(t) => t.len(),
111 ToolResultContent::Blocks(b) => b
112 .iter()
113 .map(|bb| {
114 if let ContentBlock::Text { text } = bb {
115 text.len()
116 } else {
117 50
118 }
119 })
120 .sum(),
121 };
122 tool_result_tokens += (text as u64) / 4;
123 }
124 ContentBlock::Text { text } => {
125 conversation_tokens += estimate_tokens(text);
126 }
127 ContentBlock::ToolUse { input, .. } => {
128 conversation_tokens +=
129 estimate_tokens(&serde_json::to_string(input).unwrap_or_default());
130 }
131 ContentBlock::Thinking { thinking, .. } => {
132 conversation_tokens += estimate_tokens(thinking);
133 }
134 _ => {
135 conversation_tokens += 10; }
137 }
138 }
139 }
140 }
141 }
142
143 let total =
144 system_prompt_tokens + tool_definitions_tokens + conversation_tokens + tool_result_tokens;
145
146 let compressibility = if total > 0 {
148 (tool_result_tokens as f64 * 0.9 + conversation_tokens as f64 * 0.5) / total as f64
149 } else {
150 0.0
151 };
152
153 ContextAnalysis {
154 system_prompt_tokens,
155 tool_definitions_tokens,
156 conversation_history_tokens: conversation_tokens,
157 tool_results_tokens: tool_result_tokens,
158 attachments_tokens: 0,
159 total_tokens: total,
160 compressibility,
161 }
162}
163
164pub fn suggest_compaction(analysis: &ContextAnalysis, context_limit: u64) -> CompactionStrategy {
166 if context_limit == 0 || analysis.total_tokens == 0 {
167 return CompactionStrategy::None;
168 }
169
170 let usage_pct = analysis.total_tokens as f64 / context_limit as f64;
171
172 if usage_pct < 0.75 {
173 return CompactionStrategy::None;
174 }
175
176 if analysis.category_pct(ContextCategory::ToolResults) > 0.4 && usage_pct < 0.90 {
178 return CompactionStrategy::CollapseReads {
179 expected_reduction_pct: analysis.category_pct(ContextCategory::ToolResults) * 0.5,
180 };
181 }
182
183 if usage_pct >= 0.90 {
185 return CompactionStrategy::FullCompact {
186 expected_reduction_pct: analysis.compressibility * 0.7,
187 };
188 }
189
190 CompactionStrategy::PartialCompact {
192 messages_to_compact: 0, expected_reduction_pct: analysis.compressibility * 0.5,
194 }
195}
196
197pub fn format_ctx_viz(analysis: &ContextAnalysis, context_limit: u64) -> String {
199 let usage_pct = if context_limit > 0 {
200 (analysis.total_tokens as f64 / context_limit as f64) * 100.0
201 } else {
202 0.0
203 };
204
205 let bar_width = 40;
206 let filled = ((usage_pct / 100.0) * bar_width as f64).min(bar_width as f64) as usize;
207 let bar: String = format!("[{}{}]", "#".repeat(filled), ".".repeat(bar_width - filled));
208
209 let categories = [
210 (ContextCategory::SystemPrompt, analysis.system_prompt_tokens),
211 (
212 ContextCategory::ToolDefinitions,
213 analysis.tool_definitions_tokens,
214 ),
215 (
216 ContextCategory::ConversationHistory,
217 analysis.conversation_history_tokens,
218 ),
219 (ContextCategory::ToolResults, analysis.tool_results_tokens),
220 ];
221
222 let mut lines = vec![
223 format!(
224 "Context: {} {:.1}% of {} tokens",
225 bar, usage_pct, context_limit
226 ),
227 String::new(),
228 ];
229
230 for (cat, tokens) in &categories {
231 if *tokens > 0 {
232 let pct = (*tokens as f64 / analysis.total_tokens.max(1) as f64) * 100.0;
233 lines.push(format!(
234 " {:<20} {:>8} tokens ({:.1}%)",
235 cat.label(),
236 tokens,
237 pct
238 ));
239 }
240 }
241
242 lines.push(format!(
243 "\n Compressibility: {:.0}%",
244 analysis.compressibility * 100.0
245 ));
246 lines.join("\n")
247}
248
249#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn sample_messages() -> Vec<Message> {
256 vec![
257 Message::user("Read the file src/main.rs"),
258 Message::assistant_blocks(vec![
259 ContentBlock::Text {
260 text: "Here's the file:".into(),
261 },
262 ContentBlock::ToolUse {
263 id: "t1".into(),
264 name: "Read".into(),
265 input: serde_json::json!({"file_path": "src/main.rs"}),
266 },
267 ]),
268 Message::user_blocks(vec![ContentBlock::ToolResult {
269 tool_use_id: "t1".into(),
270 content: ToolResultContent::Text("fn main() { println!(\"hello\"); }".repeat(100)),
271 is_error: Some(false),
272 }]),
273 Message::assistant("The main function prints hello."),
274 ]
275 }
276
277 #[test]
278 fn test_analyze_context() {
279 let messages = sample_messages();
280 let analysis = analyze_context(
281 Some("You are a helpful assistant."),
282 Some(r#"[{"name":"Read","description":"Read files"}]"#),
283 &messages,
284 );
285
286 assert!(analysis.system_prompt_tokens > 0);
287 assert!(analysis.tool_definitions_tokens > 0);
288 assert!(analysis.conversation_history_tokens > 0);
289 assert!(analysis.tool_results_tokens > 0);
290 assert!(analysis.total_tokens > 0);
291 assert!(analysis.compressibility > 0.0);
292 assert!(analysis.compressibility <= 1.0);
293 }
294
295 #[test]
296 fn test_category_pct() {
297 let analysis = ContextAnalysis {
298 system_prompt_tokens: 100,
299 tool_definitions_tokens: 200,
300 conversation_history_tokens: 300,
301 tool_results_tokens: 400,
302 attachments_tokens: 0,
303 total_tokens: 1000,
304 compressibility: 0.5,
305 };
306
307 assert!((analysis.category_pct(ContextCategory::SystemPrompt) - 0.1).abs() < 0.01);
308 assert!((analysis.category_pct(ContextCategory::ToolResults) - 0.4).abs() < 0.01);
309 }
310
311 #[test]
312 fn test_suggest_none_under_75() {
313 let analysis = ContextAnalysis {
314 total_tokens: 50_000,
315 ..Default::default()
316 };
317 assert!(matches!(
318 suggest_compaction(&analysis, 200_000),
319 CompactionStrategy::None
320 ));
321 }
322
323 #[test]
324 fn test_suggest_full_over_90() {
325 let analysis = ContextAnalysis {
326 total_tokens: 185_000,
327 conversation_history_tokens: 100_000,
328 tool_results_tokens: 80_000,
329 compressibility: 0.7,
330 ..Default::default()
331 };
332 assert!(matches!(
333 suggest_compaction(&analysis, 200_000),
334 CompactionStrategy::FullCompact { .. }
335 ));
336 }
337
338 #[test]
339 fn test_suggest_collapse_reads() {
340 let analysis = ContextAnalysis {
341 total_tokens: 170_000, tool_results_tokens: 90_000, conversation_history_tokens: 70_000,
344 compressibility: 0.6,
345 ..Default::default()
346 };
347 assert!(matches!(
348 suggest_compaction(&analysis, 200_000),
349 CompactionStrategy::CollapseReads { .. }
350 ));
351 }
352
353 #[test]
354 fn test_format_ctx_viz() {
355 let analysis = ContextAnalysis {
356 system_prompt_tokens: 5000,
357 tool_definitions_tokens: 3000,
358 conversation_history_tokens: 20000,
359 tool_results_tokens: 10000,
360 attachments_tokens: 0,
361 total_tokens: 38000,
362 compressibility: 0.5,
363 };
364 let viz = format_ctx_viz(&analysis, 200_000);
365 assert!(viz.contains("Context:"));
366 assert!(viz.contains("System Prompt"));
367 assert!(viz.contains("Compressibility"));
368 }
369
370 #[test]
371 fn test_empty_analysis() {
372 let analysis = analyze_context(None, None, &[]);
373 assert_eq!(analysis.total_tokens, 0);
374 assert_eq!(analysis.compressibility, 0.0);
375 }
376}