1use std::path::Path;
2use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
3use std::sync::Mutex;
4
5use crate::core::context_radar::RadarEvent;
6
7static LAST_LCTX_CALL_TS: AtomicU64 = AtomicU64::new(0);
8static HINT_COOLDOWN: AtomicU32 = AtomicU32::new(0);
9static SESSION_ID: Mutex<Option<String>> = Mutex::new(None);
10
11const COOLDOWN_CALLS: u32 = 5;
12
13const NATIVE_READ_TOOLS: &[&str] = &[
14 "Read",
15 "read",
16 "read_file",
17 "ReadFile",
18 "Grep",
19 "grep",
20 "search",
21 "ripgrep",
22];
23
24pub fn record_lctx_call() {
25 let now = std::time::SystemTime::now()
26 .duration_since(std::time::UNIX_EPOCH)
27 .unwrap_or_default()
28 .as_millis() as u64;
29 LAST_LCTX_CALL_TS.store(now, Ordering::Relaxed);
30}
31
32pub fn set_session_id(id: &str) {
33 if let Ok(mut guard) = SESSION_ID.lock() {
34 let changed = guard.as_deref() != Some(id);
35 *guard = Some(id.to_string());
36 if changed {
37 LAST_LCTX_CALL_TS.store(0, Ordering::Relaxed);
38 HINT_COOLDOWN.store(0, Ordering::Relaxed);
39 }
40 }
41}
42
43pub fn check(data_dir: &Path) -> Option<String> {
44 let mode = effective_mode();
45 if mode == "off" {
46 return None;
47 }
48
49 let aggressive = mode == "aggressive";
50 if !aggressive {
51 let counter = HINT_COOLDOWN.fetch_add(1, Ordering::Relaxed);
52 if !counter.is_multiple_of(COOLDOWN_CALLS) {
53 return None;
54 }
55 }
56
57 let last_ts = LAST_LCTX_CALL_TS.load(Ordering::Relaxed);
58 if last_ts == 0 {
59 return None;
60 }
61
62 let session_id = SESSION_ID.lock().ok().and_then(|g| g.clone());
63 let native_count = count_native_since(data_dir, last_ts, session_id.as_deref());
64 if native_count == 0 {
65 return None;
66 }
67
68 Some(format!(
69 "\n[HINT: You used native Read/Grep {native_count}x since your last ctx_read call. \
70 Use ctx_read/ctx_search instead — cached, re-reads ~13 tok, saves ~87% tokens.]"
71 ))
72}
73
74fn count_native_since(data_dir: &Path, since_ts: u64, session_id: Option<&str>) -> usize {
75 let radar_path = radar_jsonl_path(data_dir);
76 if !radar_path.exists() {
77 return 0;
78 }
79
80 let Ok(content) = std::fs::read_to_string(&radar_path) else {
81 return 0;
82 };
83
84 let mut count = 0;
85 for line in content.lines().rev() {
86 if line.is_empty() {
87 continue;
88 }
89 let event: RadarEvent = match serde_json::from_str(line) {
90 Ok(e) => e,
91 Err(_) => continue,
92 };
93
94 let event_ts_ms = event.ts * 1000;
95 if event_ts_ms < since_ts {
96 break;
97 }
98
99 if let Some(sid) = session_id {
104 match event.conversation_id.as_deref() {
105 Some(event_sid) if event_sid == sid => {}
106 _ => continue,
107 }
108 }
109
110 if event.event_type == "native_tool" {
111 if !is_read_grep_tool(event.tool_name.as_ref()) {
112 continue;
113 }
114 if let Some(ref name) = event.tool_name {
115 if name.starts_with("ctx_") || name.starts_with("mcp__lean-ctx__") {
116 continue;
117 }
118 }
119 count += 1;
120 }
121 if event.event_type == "file_read" && is_read_grep_tool(event.tool_name.as_ref()) {
122 count += 1;
123 }
124 }
125 count
126}
127
128fn is_read_grep_tool(tool_name: Option<&String>) -> bool {
129 tool_name.is_some_and(|name| NATIVE_READ_TOOLS.iter().any(|t| name == *t))
130}
131
132fn effective_mode() -> String {
133 if let Ok(v) = std::env::var("LEAN_CTX_BYPASS_HINTS") {
134 let v = v.trim().to_lowercase();
135 if matches!(v.as_str(), "off" | "on" | "aggressive") {
136 return v;
137 }
138 }
139 let cfg = crate::core::config::Config::load();
140 cfg.bypass_hints.as_deref().unwrap_or("on").to_lowercase()
141}
142
143fn radar_jsonl_path(data_dir: &Path) -> std::path::PathBuf {
144 data_dir.join("context_radar.jsonl")
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::io::Write;
151 use tempfile::TempDir;
152
153 #[test]
154 fn no_hint_when_no_native_events() {
155 let dir = TempDir::new().unwrap();
156 let path = dir.path().join("context_radar.jsonl");
157 std::fs::write(&path, "").unwrap();
158 LAST_LCTX_CALL_TS.store(1_000_000, Ordering::Relaxed);
159 assert_eq!(count_native_since(dir.path(), 1_000_000, None), 0);
160 }
161
162 #[test]
163 fn only_counts_read_grep_not_edit_write() {
164 let dir = TempDir::new().unwrap();
165 let path = dir.path().join("context_radar.jsonl");
166 let mut f = std::fs::File::create(&path).unwrap();
167 writeln!(
168 f,
169 r#"{{"ts":1100,"event_type":"native_tool","tokens":200,"tool_name":"Read"}}"#
170 )
171 .unwrap();
172 writeln!(
173 f,
174 r#"{{"ts":1200,"event_type":"native_tool","tokens":150,"tool_name":"Grep"}}"#
175 )
176 .unwrap();
177 writeln!(
178 f,
179 r#"{{"ts":1300,"event_type":"native_tool","tokens":100,"tool_name":"Edit"}}"#
180 )
181 .unwrap();
182 writeln!(
183 f,
184 r#"{{"ts":1400,"event_type":"native_tool","tokens":100,"tool_name":"Write"}}"#
185 )
186 .unwrap();
187 writeln!(
188 f,
189 r#"{{"ts":1500,"event_type":"native_tool","tokens":100,"tool_name":"Shell"}}"#
190 )
191 .unwrap();
192 drop(f);
193
194 assert_eq!(count_native_since(dir.path(), 1_000_000, None), 2);
196 }
197
198 #[test]
199 fn file_read_without_tool_name_not_counted() {
200 let dir = TempDir::new().unwrap();
201 let path = dir.path().join("context_radar.jsonl");
202 let mut f = std::fs::File::create(&path).unwrap();
203 writeln!(f, r#"{{"ts":1100,"event_type":"file_read","tokens":100}}"#).unwrap();
204 writeln!(
205 f,
206 r#"{{"ts":1200,"event_type":"file_read","tokens":100,"tool_name":"Read"}}"#
207 )
208 .unwrap();
209 writeln!(
211 f,
212 r#"{{"ts":1300,"event_type":"file_read","tokens":100,"tool_name":"SomePlugin"}}"#
213 )
214 .unwrap();
215 drop(f);
216
217 assert_eq!(count_native_since(dir.path(), 1_000_000, None), 1);
218 }
219
220 #[test]
221 fn session_filter_excludes_events_without_conversation_id() {
222 let dir = TempDir::new().unwrap();
223 let path = dir.path().join("context_radar.jsonl");
224 let mut f = std::fs::File::create(&path).unwrap();
225 writeln!(f, r#"{{"ts":1100,"event_type":"native_tool","tokens":200,"tool_name":"Read","conversation_id":"sess-1"}}"#).unwrap();
227 writeln!(
229 f,
230 r#"{{"ts":1200,"event_type":"native_tool","tokens":150,"tool_name":"Read"}}"#
231 )
232 .unwrap();
233 drop(f);
234
235 assert_eq!(count_native_since(dir.path(), 1_000_000, Some("sess-1")), 1);
237 assert_eq!(count_native_since(dir.path(), 1_000_000, None), 2);
239 }
240
241 #[test]
242 fn session_filter_excludes_other_sessions() {
243 let dir = TempDir::new().unwrap();
244 let path = dir.path().join("context_radar.jsonl");
245 let mut f = std::fs::File::create(&path).unwrap();
246 writeln!(f, r#"{{"ts":1100,"event_type":"native_tool","tokens":200,"tool_name":"Read","conversation_id":"session-A"}}"#).unwrap();
247 writeln!(f, r#"{{"ts":1200,"event_type":"native_tool","tokens":150,"tool_name":"Grep","conversation_id":"session-B"}}"#).unwrap();
248 writeln!(f, r#"{{"ts":1300,"event_type":"native_tool","tokens":100,"tool_name":"Read","conversation_id":"session-A"}}"#).unwrap();
249 drop(f);
250
251 assert_eq!(
253 count_native_since(dir.path(), 1_000_000, Some("session-A")),
254 2
255 );
256 assert_eq!(
258 count_native_since(dir.path(), 1_000_000, Some("session-B")),
259 1
260 );
261 }
262
263 #[test]
264 fn no_session_filter_counts_all() {
265 let dir = TempDir::new().unwrap();
266 let path = dir.path().join("context_radar.jsonl");
267 let mut f = std::fs::File::create(&path).unwrap();
268 writeln!(f, r#"{{"ts":1100,"event_type":"native_tool","tokens":200,"tool_name":"Read","conversation_id":"session-A"}}"#).unwrap();
269 writeln!(f, r#"{{"ts":1200,"event_type":"native_tool","tokens":150,"tool_name":"Read","conversation_id":"session-B"}}"#).unwrap();
270 drop(f);
271
272 assert_eq!(count_native_since(dir.path(), 1_000_000, None), 2);
274 }
275
276 #[test]
277 fn ignores_ctx_tools_in_native_events() {
278 let dir = TempDir::new().unwrap();
279 let path = dir.path().join("context_radar.jsonl");
280 let mut f = std::fs::File::create(&path).unwrap();
281 writeln!(
282 f,
283 r#"{{"ts":1100,"event_type":"native_tool","tokens":200,"tool_name":"ctx_read"}}"#
284 )
285 .unwrap();
286 writeln!(f, r#"{{"ts":1200,"event_type":"native_tool","tokens":150,"tool_name":"mcp__lean-ctx__ctx_search"}}"#).unwrap();
287 writeln!(
288 f,
289 r#"{{"ts":1300,"event_type":"native_tool","tokens":100,"tool_name":"Read"}}"#
290 )
291 .unwrap();
292 drop(f);
293
294 assert_eq!(count_native_since(dir.path(), 1_000_000, None), 1);
295 }
296
297 #[test]
298 fn millis_timestamp_precision() {
299 let dir = TempDir::new().unwrap();
300 let path = dir.path().join("context_radar.jsonl");
301 let mut f = std::fs::File::create(&path).unwrap();
302 writeln!(
303 f,
304 r#"{{"ts":5,"event_type":"native_tool","tokens":100,"tool_name":"Read"}}"#
305 )
306 .unwrap();
307 drop(f);
308
309 assert_eq!(count_native_since(dir.path(), 5500, None), 0);
310 assert_eq!(count_native_since(dir.path(), 4999, None), 1);
311 }
312}