Skip to main content

lean_ctx/server/
bypass_hint.rs

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        // Only count events from the same session (avoids subagent and
100        // parallel-tab false positives). Events without a conversation_id
101        // are excluded when session filtering is active — they come from
102        // IDE-internal hooks or background processes, not agent tool calls.
103        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        // Only Read + Grep count (2), not Edit/Write/Shell
195        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        // file_read with non-Read tool_name should NOT count
210        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        // Event with matching session
226        writeln!(f, r#"{{"ts":1100,"event_type":"native_tool","tokens":200,"tool_name":"Read","conversation_id":"sess-1"}}"#).unwrap();
227        // Event WITHOUT conversation_id (IDE background, hooks, etc.)
228        writeln!(
229            f,
230            r#"{{"ts":1200,"event_type":"native_tool","tokens":150,"tool_name":"Read"}}"#
231        )
232        .unwrap();
233        drop(f);
234
235        // With session filter: only the matching event counts, not the one without ID
236        assert_eq!(count_native_since(dir.path(), 1_000_000, Some("sess-1")), 1);
237        // Without session filter: both count
238        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        // Filter for session-A: only 2 events
252        assert_eq!(
253            count_native_since(dir.path(), 1_000_000, Some("session-A")),
254            2
255        );
256        // Filter for session-B: only 1 event
257        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        // No session filter → counts all
273        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}