Skip to main content

lean_ctx/core/
context_radar.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5/// ContextRadar aggregates all context sources into a single budget model.
6/// Data flows in from: hooks (JSONL), proxy introspector, rules scanner, session cache.
7pub struct ContextRadar {
8    pub events: Vec<RadarEvent>,
9    pub rules_tokens: RulesTokens,
10    pub window_size: usize,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct RadarEvent {
15    pub ts: u64,
16    pub event_type: String,
17    pub tokens: usize,
18    #[serde(default)]
19    pub tool_name: Option<String>,
20    #[serde(default)]
21    pub detail: Option<String>,
22}
23
24#[derive(Debug, Default, Clone)]
25pub struct RulesTokens {
26    pub files: Vec<(String, usize)>,
27    pub total: usize,
28}
29
30#[derive(Debug, Serialize)]
31pub struct BudgetBreakdown {
32    pub window_size: usize,
33    pub system_prompt_tokens: usize,
34    pub user_message_tokens: usize,
35    pub agent_response_tokens: usize,
36    pub lean_ctx_tool_tokens: usize,
37    pub other_mcp_tokens: usize,
38    pub native_read_tokens: usize,
39    pub shell_tokens: usize,
40    pub thinking_tokens: usize,
41    pub tracked_total: usize,
42    pub available: usize,
43    pub compaction_count: usize,
44    pub session_total_tokens: usize,
45    pub session_user_tokens: usize,
46    pub session_agent_tokens: usize,
47    pub session_lctx_tokens: usize,
48    pub session_mcp_tokens: usize,
49    pub session_native_tokens: usize,
50    pub session_shell_tokens: usize,
51    pub session_thinking_tokens: usize,
52    pub source: String,
53}
54
55impl ContextRadar {
56    pub fn new(window_size: usize) -> Self {
57        Self {
58            events: Vec::new(),
59            rules_tokens: RulesTokens::default(),
60            window_size,
61        }
62    }
63
64    pub fn load(data_dir: &Path, window_size: usize) -> Self {
65        let mut radar = Self::new(window_size);
66        radar.load_events(data_dir);
67        radar.scan_rules();
68        radar
69    }
70
71    fn load_events(&mut self, data_dir: &Path) {
72        let radar_path = data_dir.join("context_radar.jsonl");
73        let Ok(content) = std::fs::read_to_string(&radar_path) else {
74            return;
75        };
76
77        const MAX_EVENTS: usize = 50_000;
78        let all: Vec<RadarEvent> = content
79            .lines()
80            .filter_map(|line| serde_json::from_str::<RadarEvent>(line).ok())
81            .collect();
82        if all.len() > MAX_EVENTS {
83            self.events = all[all.len() - MAX_EVENTS..].to_vec();
84        } else {
85            self.events = all;
86        }
87    }
88
89    pub fn scan_rules(&mut self) {
90        let Some(home) = crate::core::home::resolve_home_dir() else {
91            return;
92        };
93
94        let cwd = std::env::current_dir().unwrap_or_default();
95        let mut files: Vec<(String, usize)> = Vec::new();
96
97        let paths_to_scan: Vec<PathBuf> = vec![
98            cwd.join(".cursorrules"),
99            cwd.join("AGENTS.md"),
100            cwd.join("CLAUDE.md"),
101            cwd.join("LEAN-CTX.md"),
102            home.join(".cursor").join("rules"),
103            home.join(".cursorrules"),
104            cwd.join(".cursor").join("rules"),
105        ];
106
107        for path in &paths_to_scan {
108            if path.is_file() {
109                if let Ok(content) = std::fs::read_to_string(path) {
110                    let tokens = content.len() / 4;
111                    if tokens > 0 {
112                        files.push((path.display().to_string(), tokens));
113                    }
114                }
115            } else if path.is_dir() {
116                if let Ok(entries) = std::fs::read_dir(path) {
117                    for entry in entries.flatten() {
118                        let p = entry.path();
119                        if p.is_file() {
120                            if let Ok(content) = std::fs::read_to_string(&p) {
121                                let tokens = content.len() / 4;
122                                if tokens > 0 {
123                                    files.push((p.display().to_string(), tokens));
124                                }
125                            }
126                        }
127                    }
128                }
129            }
130        }
131
132        let total = files.iter().map(|(_, t)| *t).sum();
133        self.rules_tokens = RulesTokens { files, total };
134    }
135
136    pub fn budget_breakdown(&self) -> BudgetBreakdown {
137        let mut compaction_count = 0;
138        let mut last_compaction_idx: Option<usize> = None;
139
140        for (i, event) in self.events.iter().enumerate() {
141            if event.event_type == "compaction" {
142                compaction_count += 1;
143                last_compaction_idx = Some(i);
144            }
145        }
146
147        let current_window_start = last_compaction_idx.map_or(0, |i| i + 1);
148        let current_events = &self.events[current_window_start..];
149
150        let (mut user_cur, mut agent_cur, mut lctx_cur, mut mcp_cur) = (0, 0, 0, 0);
151        let (mut native_cur, mut shell_cur, mut thinking_cur) = (0, 0, 0);
152        let (mut user_all, mut agent_all, mut lctx_all, mut mcp_all) = (0, 0, 0, 0);
153        let (mut native_all, mut shell_all, mut thinking_all) = (0, 0, 0);
154
155        for event in &self.events {
156            Self::classify_event(
157                event,
158                &mut user_all,
159                &mut agent_all,
160                &mut lctx_all,
161                &mut mcp_all,
162                &mut native_all,
163                &mut shell_all,
164                &mut thinking_all,
165            );
166        }
167        for event in current_events {
168            Self::classify_event(
169                event,
170                &mut user_cur,
171                &mut agent_cur,
172                &mut lctx_cur,
173                &mut mcp_cur,
174                &mut native_cur,
175                &mut shell_cur,
176                &mut thinking_cur,
177            );
178        }
179
180        let system_prompt_tokens = self.rules_tokens.total;
181        let tracked_total = system_prompt_tokens
182            + user_cur
183            + agent_cur
184            + lctx_cur
185            + mcp_cur
186            + native_cur
187            + shell_cur;
188        let available = self.window_size.saturating_sub(tracked_total);
189
190        let session_total = system_prompt_tokens
191            + user_all
192            + agent_all
193            + lctx_all
194            + mcp_all
195            + native_all
196            + shell_all;
197
198        BudgetBreakdown {
199            window_size: self.window_size,
200            system_prompt_tokens,
201            user_message_tokens: user_cur,
202            agent_response_tokens: agent_cur,
203            lean_ctx_tool_tokens: lctx_cur,
204            other_mcp_tokens: mcp_cur,
205            native_read_tokens: native_cur,
206            shell_tokens: shell_cur,
207            thinking_tokens: thinking_cur,
208            tracked_total,
209            available,
210            compaction_count,
211            session_total_tokens: session_total,
212            session_user_tokens: user_all,
213            session_agent_tokens: agent_all,
214            session_lctx_tokens: lctx_all,
215            session_mcp_tokens: mcp_all,
216            session_native_tokens: native_all,
217            session_shell_tokens: shell_all,
218            session_thinking_tokens: thinking_all,
219            source: "hooks + rules-scan".to_string(),
220        }
221    }
222
223    fn classify_event(
224        event: &RadarEvent,
225        user: &mut usize,
226        agent: &mut usize,
227        lctx: &mut usize,
228        mcp: &mut usize,
229        native: &mut usize,
230        shell: &mut usize,
231        thinking: &mut usize,
232    ) {
233        match event.event_type.as_str() {
234            "user_message" => *user += event.tokens,
235            "agent_response" => *agent += event.tokens,
236            "mcp_call" => {
237                let is_leanctx = event
238                    .detail
239                    .as_deref()
240                    .is_some_and(|d| d.contains("lean-ctx"))
241                    || event
242                        .tool_name
243                        .as_deref()
244                        .is_some_and(|t| t.starts_with("ctx_"));
245                if is_leanctx {
246                    *lctx += event.tokens;
247                } else {
248                    *mcp += event.tokens;
249                }
250            }
251            "native_tool" | "file_read" => *native += event.tokens,
252            "shell" => *shell += event.tokens,
253            "thinking" => *thinking += event.tokens,
254            _ => {}
255        }
256    }
257
258    pub fn format_display(&self) -> String {
259        let b = self.budget_breakdown();
260        let pct = |tokens: usize| -> f64 {
261            if b.window_size == 0 {
262                0.0
263            } else {
264                (tokens as f64 / b.window_size as f64 * 100.0).min(100.0)
265            }
266        };
267        let bar = |tokens: usize| -> String {
268            let width = (pct(tokens) / 2.0).min(40.0) as usize;
269            "█".repeat(width)
270        };
271
272        let mut out = String::new();
273        out.push_str(&format!(
274            "CONTEXT RADAR — Current Window ({:.0}k)\n",
275            b.window_size as f64 / 1000.0
276        ));
277        if b.compaction_count > 0 {
278            out.push_str(&format!(
279                "  (after {} compaction(s) — showing current window only)\n",
280                b.compaction_count
281            ));
282        }
283        out.push_str(&format!(
284            "  System Prompt (est.): {:>8} tok {:>5.1}%  {}\n",
285            fmt_num(b.system_prompt_tokens),
286            pct(b.system_prompt_tokens),
287            bar(b.system_prompt_tokens)
288        ));
289        out.push_str(&format!(
290            "  User Messages:        {:>8} tok {:>5.1}%  {}\n",
291            fmt_num(b.user_message_tokens),
292            pct(b.user_message_tokens),
293            bar(b.user_message_tokens)
294        ));
295        out.push_str(&format!(
296            "  Agent Responses:      {:>8} tok {:>5.1}%  {}\n",
297            fmt_num(b.agent_response_tokens),
298            pct(b.agent_response_tokens),
299            bar(b.agent_response_tokens)
300        ));
301        out.push_str(&format!(
302            "  lean-ctx Tools:       {:>8} tok {:>5.1}%  {}\n",
303            fmt_num(b.lean_ctx_tool_tokens),
304            pct(b.lean_ctx_tool_tokens),
305            bar(b.lean_ctx_tool_tokens)
306        ));
307        out.push_str(&format!(
308            "  Other MCP:            {:>8} tok {:>5.1}%  {}\n",
309            fmt_num(b.other_mcp_tokens),
310            pct(b.other_mcp_tokens),
311            bar(b.other_mcp_tokens)
312        ));
313        out.push_str(&format!(
314            "  Native Reads:         {:>8} tok {:>5.1}%  {}\n",
315            fmt_num(b.native_read_tokens),
316            pct(b.native_read_tokens),
317            bar(b.native_read_tokens)
318        ));
319        out.push_str(&format!(
320            "  Shell Output:         {:>8} tok {:>5.1}%  {}\n",
321            fmt_num(b.shell_tokens),
322            pct(b.shell_tokens),
323            bar(b.shell_tokens)
324        ));
325        out.push_str("  ──────────────────────────────────────────\n");
326        out.push_str(&format!(
327            "  TRACKED:              {:>8} tok {:>5.1}%\n",
328            fmt_num(b.tracked_total),
329            pct(b.tracked_total)
330        ));
331        out.push_str(&format!(
332            "  Available:            {:>8} tok {:>5.1}%\n",
333            fmt_num(b.available),
334            pct(b.available)
335        ));
336        if b.thinking_tokens > 0 {
337            out.push_str(&format!(
338                "  Thinking (not in window): {:>5} tok\n",
339                fmt_num(b.thinking_tokens)
340            ));
341        }
342        if b.session_total_tokens > b.tracked_total {
343            out.push_str(&format!(
344                "\n  SESSION TOTAL:        {:>8} tok (across {} compaction(s))\n",
345                fmt_num(b.session_total_tokens),
346                b.compaction_count
347            ));
348        }
349        out.push_str(&format!("  Source: {}\n", b.source));
350        out
351    }
352}
353
354fn fmt_num(n: usize) -> String {
355    if n >= 1000 {
356        format!("{},{:03}", n / 1000, n % 1000)
357    } else {
358        n.to_string()
359    }
360}
361
362/// Default context window size based on client name.
363pub fn default_window_for_client(client: &str) -> usize {
364    match client.to_lowercase().as_str() {
365        "gemini" => 1_000_000,
366        "windsurf" | "zed" | "copilot" => 128_000,
367        // cursor, claude-code, claude, codex, and others
368        _ => 200_000,
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn budget_breakdown_empty() {
378        let radar = ContextRadar::new(200_000);
379        let b = radar.budget_breakdown();
380        assert_eq!(b.window_size, 200_000);
381        assert_eq!(b.tracked_total, 0);
382        assert_eq!(b.available, 200_000);
383    }
384
385    #[test]
386    fn budget_breakdown_with_events() {
387        let mut radar = ContextRadar::new(200_000);
388        radar.events.push(RadarEvent {
389            ts: 1000,
390            event_type: "user_message".to_string(),
391            tokens: 500,
392            tool_name: None,
393            detail: None,
394        });
395        radar.events.push(RadarEvent {
396            ts: 1001,
397            event_type: "agent_response".to_string(),
398            tokens: 2000,
399            tool_name: None,
400            detail: None,
401        });
402        radar.events.push(RadarEvent {
403            ts: 1002,
404            event_type: "shell".to_string(),
405            tokens: 300,
406            tool_name: None,
407            detail: Some("git status".to_string()),
408        });
409        let b = radar.budget_breakdown();
410        assert_eq!(b.user_message_tokens, 500);
411        assert_eq!(b.agent_response_tokens, 2000);
412        assert_eq!(b.shell_tokens, 300);
413        assert_eq!(b.tracked_total, 2800);
414        assert_eq!(b.available, 200_000 - 2800);
415    }
416
417    #[test]
418    fn budget_breakdown_resets_after_compaction() {
419        let mut radar = ContextRadar::new(100_000);
420        radar.events.push(RadarEvent {
421            ts: 1,
422            event_type: "user_message".to_string(),
423            tokens: 50_000,
424            tool_name: None,
425            detail: None,
426        });
427        radar.events.push(RadarEvent {
428            ts: 2,
429            event_type: "compaction".to_string(),
430            tokens: 0,
431            tool_name: None,
432            detail: None,
433        });
434        radar.events.push(RadarEvent {
435            ts: 3,
436            event_type: "user_message".to_string(),
437            tokens: 10_000,
438            tool_name: None,
439            detail: None,
440        });
441        let b = radar.budget_breakdown();
442        assert_eq!(
443            b.user_message_tokens, 10_000,
444            "only counts since compaction"
445        );
446        assert_eq!(b.available, 90_000);
447        assert_eq!(b.compaction_count, 1);
448        assert_eq!(b.session_user_tokens, 60_000, "session total includes all");
449    }
450
451    #[test]
452    fn format_display_not_empty() {
453        let radar = ContextRadar::new(200_000);
454        let display = radar.format_display();
455        assert!(display.contains("CONTEXT RADAR"));
456        assert!(display.contains("200k"));
457    }
458
459    #[test]
460    fn default_window_sizes() {
461        assert_eq!(default_window_for_client("cursor"), 200_000);
462        assert_eq!(default_window_for_client("gemini"), 1_000_000);
463        assert_eq!(default_window_for_client("windsurf"), 128_000);
464    }
465}