Skip to main content

lean_ctx/server/
context_gate.rs

1use crate::core::context_field::{ContextItemId, ContextState};
2use crate::core::context_overlay::{OverlayOp, OverlayStore};
3
4#[derive(Debug, Clone)]
5pub struct PreDispatchResult {
6    pub overridden_mode: Option<String>,
7    pub reason: Option<&'static str>,
8}
9
10#[derive(Debug, Clone)]
11pub struct PostDispatchResult {
12    pub eviction_hint: Option<String>,
13    pub elicitation_hint: Option<String>,
14}
15
16pub fn pre_dispatch_read(
17    path: &str,
18    requested_mode: &str,
19    task: Option<&str>,
20    project_root: Option<&str>,
21) -> PreDispatchResult {
22    if requested_mode == "diff" {
23        return PreDispatchResult {
24            overridden_mode: None,
25            reason: None,
26        };
27    }
28
29    if let Some(root) = project_root {
30        let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
31        if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
32            return result;
33        }
34    }
35
36    if requested_mode == "full" {
37        return PreDispatchResult {
38            overridden_mode: None,
39            reason: None,
40        };
41    }
42
43    if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
44        if bt.should_force_full(path) {
45            return PreDispatchResult {
46                overridden_mode: Some("full".to_string()),
47                reason: Some("bounce-prevention"),
48            };
49        }
50    }
51
52    if let Some(task_str) = task {
53        let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
54        let norm = crate::core::pathutil::normalize_tool_path(path);
55        let is_target = intent
56            .targets
57            .iter()
58            .any(|t| norm.ends_with(t) || norm.contains(t));
59        if is_target {
60            return PreDispatchResult {
61                overridden_mode: Some("full".to_string()),
62                reason: Some("intent-target"),
63            };
64        }
65    }
66
67    if let Some(root) = project_root {
68        if let Some(index) = try_load_graph(root) {
69            let related = index.get_related(path, 1);
70            if let Some(task_str) = task {
71                let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
72                for target in &intent.targets {
73                    let target_related = index.get_related(target, 1);
74                    let norm = crate::core::pathutil::normalize_tool_path(path);
75                    if target_related
76                        .iter()
77                        .any(|r| r.contains(&norm) || norm.contains(r))
78                    {
79                        return PreDispatchResult {
80                            overridden_mode: Some("map".to_string()),
81                            reason: Some("graph-direct-import"),
82                        };
83                    }
84                }
85            }
86            if !related.is_empty() && requested_mode == "auto" {
87                let reverse_deps = index.get_reverse_deps(path, 1);
88                if reverse_deps.len() > 3 {
89                    return PreDispatchResult {
90                        overridden_mode: Some("map".to_string()),
91                        reason: Some("graph-hub-file"),
92                    };
93                }
94            }
95        }
96    }
97
98    if let Some(root) = project_root {
99        if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root) {
100            let norm = crate::core::pathutil::normalize_tool_path(path);
101            let mentions = knowledge
102                .facts
103                .iter()
104                .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
105                .count();
106            if mentions >= 3 {
107                return PreDispatchResult {
108                    overridden_mode: Some("map".to_string()),
109                    reason: Some("knowledge-high-relevance"),
110                };
111            }
112        }
113    }
114
115    PreDispatchResult {
116        overridden_mode: None,
117        reason: None,
118    }
119}
120
121fn check_overlay_mode_override(
122    path: &str,
123    requested_mode: &str,
124    overlay: &OverlayStore,
125) -> Option<PreDispatchResult> {
126    let item_id = ContextItemId::from_file(path);
127    let overlays = overlay.for_item(&item_id);
128
129    for ov in overlays.iter().rev() {
130        match &ov.operation {
131            OverlayOp::SetView(view) => {
132                let mode_str = view.as_str();
133                if mode_str != requested_mode {
134                    return Some(PreDispatchResult {
135                        overridden_mode: Some(mode_str.to_string()),
136                        reason: Some("overlay-set-view"),
137                    });
138                }
139            }
140            OverlayOp::Pin { .. } if requested_mode != "full" => {
141                return Some(PreDispatchResult {
142                    overridden_mode: Some("full".to_string()),
143                    reason: Some("pinned"),
144                });
145            }
146            OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
147                return Some(PreDispatchResult {
148                    overridden_mode: Some("signatures".to_string()),
149                    reason: Some("excluded"),
150                });
151            }
152            _ => {}
153        }
154    }
155    None
156}
157
158pub fn post_dispatch_record(
159    path: &str,
160    mode: &str,
161    original_tokens: usize,
162    sent_tokens: usize,
163    ledger: &mut crate::core::context_ledger::ContextLedger,
164    overlay: &crate::core::context_overlay::OverlayStore,
165) -> PostDispatchResult {
166    ledger.record(path, mode, original_tokens, sent_tokens);
167
168    let item_id = ContextItemId::from_file(path);
169    let state = overlay.apply_to_state(&item_id, ContextState::Included);
170
171    if state == ContextState::Excluded {
172        return PostDispatchResult {
173            eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
174            elicitation_hint: None,
175        };
176    }
177
178    let elicitation =
179        super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
180            .map(|s| s.format_fallback_hint());
181
182    let pressure = ledger.pressure();
183    if pressure.utilization > 0.9 {
184        let candidates = ledger.eviction_candidates_by_phi(3);
185        if !candidates.is_empty() {
186            let names: Vec<_> = candidates
187                .iter()
188                .take(3)
189                .map(|p| crate::core::protocol::shorten_path(p))
190                .collect();
191            return PostDispatchResult {
192                eviction_hint: Some(format!(
193                    "Context pressure {:.0}%. Consider evicting: {}",
194                    pressure.utilization * 100.0,
195                    names.join(", ")
196                )),
197                elicitation_hint: elicitation,
198            };
199        }
200    }
201
202    PostDispatchResult {
203        eviction_hint: None,
204        elicitation_hint: elicitation,
205    }
206}
207
208fn try_load_graph(project_root: &str) -> Option<crate::core::graph_index::ProjectIndex> {
209    crate::core::graph_index::ProjectIndex::load(project_root)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn pre_dispatch_passthrough_for_full() {
218        let result = pre_dispatch_read("src/main.rs", "full", None, None);
219        assert!(result.overridden_mode.is_none());
220    }
221
222    #[test]
223    fn pre_dispatch_passthrough_for_diff() {
224        let result = pre_dispatch_read("src/main.rs", "diff", None, None);
225        assert!(result.overridden_mode.is_none());
226    }
227
228    #[test]
229    fn pre_dispatch_no_override_without_signals() {
230        let result = pre_dispatch_read("src/unknown.rs", "auto", None, None);
231        assert!(result.overridden_mode.is_none());
232    }
233
234    #[test]
235    fn pre_dispatch_bounce_prevention_forces_full() {
236        {
237            let mut bt = crate::core::bounce_tracker::global().lock().unwrap();
238            bt.set_seq(1);
239            bt.record_read("src/bouncy.yml", "map", 30, 400);
240            bt.set_seq(2);
241            bt.record_read("src/bouncy.yml", "full", 400, 400);
242            bt.set_seq(3);
243            bt.record_read("a2.yml", "map", 30, 400);
244            bt.set_seq(4);
245            bt.record_read("a2.yml", "full", 400, 400);
246            bt.set_seq(5);
247            bt.record_read("a3.yml", "map", 30, 400);
248            bt.set_seq(6);
249            bt.record_read("a3.yml", "full", 400, 400);
250        }
251        let result = pre_dispatch_read("new.yml", "auto", None, None);
252        assert_eq!(result.overridden_mode, Some("full".to_string()));
253        assert_eq!(result.reason, Some("bounce-prevention"));
254    }
255
256    #[test]
257    fn overlay_pin_forces_full_mode() {
258        let dir = tempfile::tempdir().expect("tmp dir");
259        let root = dir.path();
260        let mut store = OverlayStore::new();
261        let target = ContextItemId::from_file("src/important.rs");
262        store.add(crate::core::context_overlay::ContextOverlay::new(
263            target,
264            OverlayOp::Pin { verbatim: false },
265            crate::core::context_overlay::OverlayScope::Project,
266            String::new(),
267            crate::core::context_overlay::OverlayAuthor::User,
268        ));
269        store.save_project(root).unwrap();
270
271        let result = pre_dispatch_read(
272            "src/important.rs",
273            "auto",
274            None,
275            Some(root.to_str().unwrap()),
276        );
277        assert_eq!(result.overridden_mode, Some("full".to_string()));
278        assert_eq!(result.reason, Some("pinned"));
279    }
280
281    #[test]
282    fn overlay_exclude_forces_signatures_mode() {
283        let dir = tempfile::tempdir().expect("tmp dir");
284        let root = dir.path();
285        let mut store = OverlayStore::new();
286        let target = ContextItemId::from_file("src/noisy.rs");
287        store.add(crate::core::context_overlay::ContextOverlay::new(
288            target,
289            OverlayOp::Exclude {
290                reason: "noise".to_string(),
291            },
292            crate::core::context_overlay::OverlayScope::Project,
293            String::new(),
294            crate::core::context_overlay::OverlayAuthor::User,
295        ));
296        store.save_project(root).unwrap();
297
298        let result = pre_dispatch_read("src/noisy.rs", "auto", None, Some(root.to_str().unwrap()));
299        assert_eq!(result.overridden_mode, Some("signatures".to_string()));
300        assert_eq!(result.reason, Some("excluded"));
301    }
302
303    #[test]
304    fn overlay_set_view_forces_specified_mode() {
305        let dir = tempfile::tempdir().expect("tmp dir");
306        let root = dir.path();
307        let mut store = OverlayStore::new();
308        let target = ContextItemId::from_file("src/big.rs");
309        store.add(crate::core::context_overlay::ContextOverlay::new(
310            target,
311            OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
312            crate::core::context_overlay::OverlayScope::Project,
313            String::new(),
314            crate::core::context_overlay::OverlayAuthor::User,
315        ));
316        store.save_project(root).unwrap();
317
318        let result = pre_dispatch_read("src/big.rs", "auto", None, Some(root.to_str().unwrap()));
319        assert_eq!(result.overridden_mode, Some("map".to_string()));
320        assert_eq!(result.reason, Some("overlay-set-view"));
321    }
322}