1use lean_ctx::core::context_field::{ContextItemId, ContextState};
3use lean_ctx::core::context_ledger::{ContextLedger, PressureAction};
4use lean_ctx::core::context_overlay::{OverlayOp, OverlayStore};
5
6pub(super) fn protected_path_requested(
8 args: Option<&serde_json::Map<String, serde_json::Value>>,
9) -> bool {
10 let Some(args) = args else {
11 return false;
12 };
13 let config = lean_ctx::core::config::Config::load();
14 if config.protection.file_patterns.is_empty() {
15 return false;
16 }
17 ["path", "file_path", "filePath"].iter().any(|key| {
18 args.get(*key)
19 .and_then(serde_json::Value::as_str)
20 .is_some_and(|path| config.protection.path_is_protected(path))
21 })
22}
23
24fn is_precise_pinned_mode(requested_mode: &str) -> bool {
35 requested_mode
36 .parse::<crate::tools::ctx_read::ReadMode>()
37 .is_ok_and(|m| m.is_precise_pinned_read())
38}
39
40#[derive(Debug, Clone)]
41pub struct PreDispatchResult {
42 pub overridden_mode: Option<String>,
43 pub reason: Option<&'static str>,
44 pub budget_blocked: bool,
45 pub budget_warning: Option<String>,
46 pub triage_filter_level: u8,
48}
49
50#[derive(Debug, Clone)]
51pub struct PostDispatchResult {
52 pub eviction_hint: Option<String>,
53 pub elicitation_hint: Option<String>,
54 pub resource_changed: bool,
55}
56
57pub fn knowledge_advice(
59 query: &str,
60) -> lean_ctx::core::knowledge_router::gate_integration::KnowledgeAdvice {
61 let current = lean_ctx::core::context_kernel::ContextState::new(
62 Vec::new(),
63 lean_ctx::core::knowledge::KnowledgeQuery::default(),
64 );
65 lean_ctx::core::knowledge_router::gate_integration::KnowledgeGateAdvisor::advise(
66 query, ¤t,
67 )
68}
69
70pub fn pre_dispatch_read(
71 path: &str,
72 requested_mode: &str,
73 task: Option<&str>,
74 project_root: Option<&str>,
75) -> PreDispatchResult {
76 pre_dispatch_read_for_agent(path, requested_mode, task, project_root, None, false)
77}
78
79enum AgentBudgetDecision {
80 Allowed,
81 Warning(String),
82 Exceeded(PreDispatchResult),
83}
84
85fn no_change_result() -> PreDispatchResult {
86 PreDispatchResult {
87 overridden_mode: None,
88 reason: None,
89 budget_blocked: false,
90 budget_warning: None,
91 triage_filter_level: 0,
92 }
93}
94
95fn check_agent_budget(
96 path: &str,
97 requested_mode: &str,
98 agent_id: Option<&str>,
99) -> AgentBudgetDecision {
100 let Some(aid) = agent_id else {
101 return AgentBudgetDecision::Allowed;
102 };
103
104 let estimated_tokens = estimate_read_tokens(path, requested_mode);
105 match lean_ctx::core::agent_budget::check_budget(aid, estimated_tokens) {
106 lean_ctx::core::agent_budget::BudgetCheckResult::Exceeded { limit, consumed } => {
107 let mut result = no_change_result();
108 result.reason = Some("agent-budget-exceeded");
109 result.budget_blocked = true;
110 result.budget_warning = Some(format!(
111 "Agent budget exceeded: {consumed}/{limit} tokens consumed. Reset via ctx_session or set a higher limit."
112 ));
113 AgentBudgetDecision::Exceeded(result)
114 }
115 lean_ctx::core::agent_budget::BudgetCheckResult::Warning {
116 remaining,
117 percent_used,
118 } => AgentBudgetDecision::Warning(format!(
119 "[BUDGET WARNING] Agent '{aid}' at {:.0}% budget ({remaining} tokens remaining)",
120 percent_used * 100.0
121 )),
122 lean_ctx::core::agent_budget::BudgetCheckResult::Allowed { .. } => {
123 AgentBudgetDecision::Allowed
124 }
125 }
126}
127
128pub fn pre_dispatch_pinned_read_for_agent(
133 path: &str,
134 requested_mode: &str,
135 agent_id: Option<&str>,
136) -> PreDispatchResult {
137 let no_change = no_change_result();
138 match check_agent_budget(path, requested_mode, agent_id) {
139 AgentBudgetDecision::Exceeded(result) => result,
140 AgentBudgetDecision::Warning(warning) => PreDispatchResult {
141 budget_warning: Some(warning),
142 ..no_change
143 },
144 AgentBudgetDecision::Allowed => no_change,
145 }
146}
147
148pub fn pre_dispatch_read_for_agent(
155 path: &str,
156 requested_mode: &str,
157 task: Option<&str>,
158 project_root: Option<&str>,
159 agent_id: Option<&str>,
160 fresh: bool,
161) -> PreDispatchResult {
162 let no_change = no_change_result();
163
164 match check_agent_budget(path, requested_mode, agent_id) {
165 AgentBudgetDecision::Exceeded(result) => return result,
166 AgentBudgetDecision::Warning(warning) => {
167 let mut result = no_change.clone();
168 result.budget_warning = Some(warning);
169 if is_precise_pinned_mode(requested_mode) {
170 return result;
171 }
172 let rest = pre_dispatch_inner(path, requested_mode, task, project_root, fresh);
173 return PreDispatchResult {
174 budget_warning: result.budget_warning,
175 ..rest
176 };
177 }
178 AgentBudgetDecision::Allowed => {}
179 }
180
181 pre_dispatch_inner(path, requested_mode, task, project_root, fresh)
182}
183
184fn path_matches_target(norm: &str, target: &str) -> bool {
193 const MIN_TARGET_LEN: usize = 4;
194 let target = target.trim().trim_matches('/');
195 if target.len() < MIN_TARGET_LEN {
196 return false;
197 }
198 if norm == target || norm.ends_with(&format!("/{target}")) {
200 return true;
201 }
202 norm.split('/').any(|component| {
204 component == target
205 || component
206 .rsplit_once('.')
207 .is_some_and(|(stem, _)| stem == target)
208 })
209}
210
211fn is_explicit_compressing_mode(mode: &str) -> bool {
214 use crate::tools::ctx_read::ReadMode;
215 mode.parse::<ReadMode>().is_ok_and(|parsed| {
216 parsed.is_lossy_summary() || matches!(parsed, ReadMode::Reference | ReadMode::Density(_))
217 })
218}
219
220fn pre_dispatch_inner(
221 path: &str,
222 requested_mode: &str,
223 task: Option<&str>,
224 project_root: Option<&str>,
225 fresh: bool,
226) -> PreDispatchResult {
227 let no_change = PreDispatchResult {
228 overridden_mode: None,
229 reason: None,
230 budget_blocked: false,
231 budget_warning: None,
232 triage_filter_level: 0,
233 };
234
235 if is_precise_pinned_mode(requested_mode) {
236 return no_change;
237 }
238
239 if let Some(root) = project_root {
240 let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
241 if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
242 return result;
243 }
244 }
245
246 if requested_mode == "full" {
249 return no_change;
250 }
251
252 if !fresh
256 && let Ok(bt) = lean_ctx::core::bounce_tracker::global().lock()
257 && bt.should_force_full(path)
258 {
259 return PreDispatchResult {
260 overridden_mode: Some("full".to_string()),
261 reason: Some("bounce-prevention"),
262 budget_blocked: false,
263 budget_warning: None,
264 triage_filter_level: 0,
265 };
266 }
267
268 if !fresh
273 && is_explicit_compressing_mode(requested_mode)
274 && lean_ctx::core::path_mode_memory::should_force_full(path)
275 {
276 return PreDispatchResult {
277 overridden_mode: Some("full".to_string()),
278 reason: Some("learned-full"),
279 budget_blocked: false,
280 budget_warning: None,
281 triage_filter_level: 0,
282 };
283 }
284
285 if let Some(task_str) = task
286 && !fresh
287 {
288 let intent = lean_ctx::core::intent_engine::StructuredIntent::from_query(task_str);
289 let norm = lean_ctx::core::pathutil::normalize_tool_path(path);
290 let is_target = intent.targets.iter().any(|t| path_matches_target(&norm, t));
291 if is_target {
292 return PreDispatchResult {
293 overridden_mode: Some("full".to_string()),
294 reason: Some("intent-target"),
295 budget_blocked: false,
296 budget_warning: None,
297 triage_filter_level: 0,
298 };
299 }
300 }
301
302 if let Some(root) = project_root
303 && let Some(open) = try_load_graph(root)
304 {
305 let gp = &open.provider;
306 let related = gp.related(path, 1);
307 if let Some(task_str) = task {
308 let intent = lean_ctx::core::intent_engine::StructuredIntent::from_query(task_str);
309 for target in &intent.targets {
310 let target_related = gp.related(target, 1);
311 let norm = lean_ctx::core::pathutil::normalize_tool_path(path);
312 if target_related
313 .iter()
314 .any(|r| r.contains(&norm) || norm.contains(r))
315 {
316 return PreDispatchResult {
317 overridden_mode: Some("map".to_string()),
318 reason: Some("graph-direct-import"),
319 budget_blocked: false,
320 budget_warning: None,
321 triage_filter_level: 0,
322 };
323 }
324 }
325 }
326 if !related.is_empty() && requested_mode == "auto" {
327 let reverse_deps = gp.dependents(path);
328 if reverse_deps.len() > 3 {
329 return PreDispatchResult {
330 overridden_mode: Some("map".to_string()),
331 reason: Some("graph-hub-file"),
332 budget_blocked: false,
333 budget_warning: None,
334 triage_filter_level: 0,
335 };
336 }
337 }
338 }
339
340 if let Some(root) = project_root
341 && let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(root)
342 {
343 let norm = lean_ctx::core::pathutil::normalize_tool_path(path);
344 let mentions = knowledge
345 .facts
346 .iter()
347 .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
348 .count();
349 if mentions >= 3 {
350 return PreDispatchResult {
351 overridden_mode: Some("map".to_string()),
352 reason: Some("knowledge-high-relevance"),
353 budget_blocked: false,
354 budget_warning: None,
355 triage_filter_level: 0,
356 };
357 }
358 }
359
360 no_change
361}
362
363fn estimate_read_tokens(path: &str, mode: &str) -> usize {
364 let file_size = std::fs::metadata(path).map_or(4000, |m| m.len() as usize);
365 let char_estimate = file_size;
366 let full_tokens = char_estimate / 4;
367 match mode {
368 "signatures" => full_tokens / 5,
369 "map" => full_tokens / 3,
370 "aggressive" | "entropy" => full_tokens / 4,
371 "diff" => full_tokens / 10,
372 _ if mode.starts_with("lines:") => {
373 if let Some(range) = mode.strip_prefix("lines:") {
374 let mut selected = 0usize;
378 for part in range.split(',') {
379 let part = part.trim();
380 selected += if let Some((s, e)) = part.split_once('-') {
381 let start = s.trim().parse::<usize>().unwrap_or(1);
382 let end = e.trim().parse::<usize>().unwrap_or(start + 100);
383 end.saturating_sub(start) + 1
384 } else {
385 1
386 };
387 }
388 if selected == 0 {
389 full_tokens / 10
390 } else {
391 selected * 10
392 }
393 } else {
394 full_tokens / 10
395 }
396 }
397 _ => full_tokens,
398 }
399}
400
401fn check_overlay_mode_override(
402 path: &str,
403 requested_mode: &str,
404 overlay: &OverlayStore,
405) -> Option<PreDispatchResult> {
406 let item_id = ContextItemId::from_file(path);
407 let overlays = overlay.for_item(&item_id);
408
409 for ov in overlays.iter().rev() {
410 match &ov.operation {
411 OverlayOp::SetView(view) => {
412 let mode_str = view.as_str();
413 if mode_str != requested_mode {
414 return Some(PreDispatchResult {
415 overridden_mode: Some(mode_str.to_string()),
416 reason: Some("overlay-set-view"),
417 budget_blocked: false,
418 budget_warning: None,
419 triage_filter_level: 0,
420 });
421 }
422 }
423 OverlayOp::Pin { .. } if requested_mode != "full" => {
424 return Some(PreDispatchResult {
425 overridden_mode: Some("full".to_string()),
426 reason: Some("pinned"),
427 budget_blocked: false,
428 budget_warning: None,
429 triage_filter_level: 0,
430 });
431 }
432 OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
433 return Some(PreDispatchResult {
434 overridden_mode: Some("signatures".to_string()),
435 reason: Some("excluded"),
436 budget_blocked: false,
437 budget_warning: None,
438 triage_filter_level: 0,
439 });
440 }
441 _ => {}
442 }
443 }
444 None
445}
446
447pub fn post_dispatch_record(
448 path: &str,
449 mode: &str,
450 original_tokens: usize,
451 sent_tokens: usize,
452 ledger: &mut ContextLedger,
453 overlay: &OverlayStore,
454) -> PostDispatchResult {
455 post_dispatch_record_with_task(
456 path,
457 mode,
458 original_tokens,
459 sent_tokens,
460 ledger,
461 overlay,
462 None,
463 None,
464 )
465}
466
467pub fn post_dispatch_record_with_task(
468 path: &str,
469 mode: &str,
470 original_tokens: usize,
471 sent_tokens: usize,
472 ledger: &mut ContextLedger,
473 overlay: &OverlayStore,
474 task: Option<&str>,
475 project_root: Option<&str>,
476) -> PostDispatchResult {
477 let prev_count = ledger.entries.len();
478 let prev_pressure = ledger.pressure().recommendation;
479
480 ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
481
482 let item_id = ContextItemId::from_file(path);
483 let state = overlay.apply_to_state(&item_id, ContextState::Included);
484
485 if state == ContextState::Excluded {
486 return PostDispatchResult {
487 eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
488 elicitation_hint: None,
489 resource_changed: true,
490 };
491 }
492
493 let elicitation =
494 super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
495 .map(|s| s.format_fallback_hint());
496
497 let pressure = ledger.pressure();
498
499 let ignited = ledger.ignite_high_salience();
503
504 apply_reinjection_plan(ledger, &pressure.recommendation);
505
506 let new_entry = ledger.entries.len() != prev_count;
507 let pressure_shifted = pressure.recommendation != prev_pressure;
508 let resource_changed = new_entry || pressure_shifted || !ignited.is_empty();
509
510 if pressure.utilization > 0.9 {
511 let candidates = ledger.eviction_candidates_by_phi(3);
512 if !candidates.is_empty() {
513 let names: Vec<_> = candidates
517 .iter()
518 .take(3)
519 .map(|p| eviction_target_display(p, project_root))
520 .collect();
521 return PostDispatchResult {
522 eviction_hint: Some(format!(
523 "Context pressure {:.0}%. Evict: ctx_control(action=\"evict\", targets=\"{}\")",
524 pressure.utilization * 100.0,
525 names.join(", ")
526 )),
527 elicitation_hint: elicitation,
528 resource_changed,
529 };
530 }
531 }
532
533 PostDispatchResult {
534 eviction_hint: None,
535 elicitation_hint: elicitation,
536 resource_changed,
537 }
538}
539
540fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
544 if let Some(root) = project_root.filter(|r| !r.is_empty()) {
545 let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
546 if let Some(rel) = path.strip_prefix(&root_prefix)
547 && !rel.is_empty()
548 {
549 return rel.to_string();
550 }
551 }
552 path.to_string()
553}
554
555fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
556 if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
557 {
558 return;
559 }
560 for entry in &mut ledger.entries {
561 if entry.state == Some(ContextState::Pinned) {
563 continue;
564 }
565 if entry.mode == "full" {
566 entry.mode = "map".to_string();
567 }
568 }
569}
570
571fn try_load_graph(project_root: &str) -> Option<lean_ctx::core::graph_provider::OpenGraphProvider> {
576 lean_ctx::core::graph_provider::open_ready(project_root)
577}
578
579pub fn triage_filter_level(profile: &lean_ctx::core::triage::profile::TaskProfileLocal) -> u8 {
589 use lean_ctx::core::triage::confidence::ACTIONABLE_FLOOR_MILLI;
590 if profile.confidence_milli < ACTIONABLE_FLOOR_MILLI || profile.context_need_milli == 0 {
592 return 0;
593 }
594 u8::from(profile.context_need_milli < 600)
603}
604
605pub fn apply_triage_filter(
607 output: &str,
608 profile: &lean_ctx::core::triage::profile::TaskProfileLocal,
609 level: u8,
610) -> (String, usize) {
611 if level == 0 || output.chars().count() < 500 {
612 return (output.to_string(), 0);
613 }
614
615 let trimmed = output.trim_start();
618 if (trimmed.starts_with('{') || trimmed.starts_with('['))
619 && serde_json::from_str::<serde_json::Value>(trimmed).is_ok()
620 {
621 return (output.to_string(), 0);
622 }
623
624 let lines: Vec<&str> = output.lines().collect();
625
626 if let Some(json_start) = lines.iter().position(|l| {
628 let t = l.trim_start();
629 t.starts_with('{') || t.starts_with('[')
630 }) {
631 let json_portion = lines[json_start..].join("\n");
632 if serde_json::from_str::<serde_json::Value>(&json_portion).is_ok() {
633 return (output.to_string(), 0);
634 }
635 }
636 if output.contains("<protect>") && lean_ctx::core::config::Config::load().protection.tags {
641 return (output.to_string(), 0);
642 }
643 let keep: std::collections::HashSet<usize> = match level {
645 1 => lines
646 .iter()
647 .enumerate()
648 .filter(|(_, line)| !is_boilerplate_line(line.trim_start()))
649 .map(|(i, _)| i)
650 .collect(),
651 2 => {
652 let keywords = extract_task_keywords(&profile.task_class, &profile.intent);
653 if lean_ctx::core::triage::markdown::looks_like_markdown(&lines) {
654 lean_ctx::core::triage::markdown::keep_indices(&lines, &keywords)
655 } else {
656 lines
665 .iter()
666 .enumerate()
667 .filter(|(_, line)| !is_boilerplate_line(line.trim_start()))
668 .map(|(i, _)| i)
669 .collect()
670 }
671 }
672 _ => return (output.to_string(), 0),
673 };
674
675 let removed = lines.len().saturating_sub(keep.len());
677 if removed == 0 {
678 return (output.to_string(), 0);
679 }
680 if keep.is_empty() {
684 return (output.to_string(), 0);
685 }
686 if lean_ctx::core::triage::markdown::is_heading_only_collapse(&lines, &keep) {
690 return (output.to_string(), 0);
691 }
692 let mut result = String::with_capacity(output.len());
693 let mut consecutive_omitted: usize = 0;
694 for (i, line) in lines.iter().enumerate() {
695 if keep.contains(&i) {
696 if consecutive_omitted > 0 {
697 result.push_str(&format!(
698 " [...{consecutive_omitted} lines omitted by triage...]\n"
699 ));
700 consecutive_omitted = 0;
701 }
702 result.push_str(line);
703 result.push('\n');
704 } else {
705 consecutive_omitted += 1;
706 }
707 }
708 if consecutive_omitted > 0 {
709 result.push_str(&format!(
710 " [...{consecutive_omitted} lines omitted by triage...]\n"
711 ));
712 }
713 result.push_str(&format!(
714 "[lean-ctx: {removed} lines filtered by triage (level {level}) — rerun with raw=true for the unfiltered output]"
715 ));
716 (result, removed)
717}
718
719fn extract_task_keywords(task_class: &str, intent: &str) -> Vec<String> {
720 let mut keywords = task_class
721 .split(|character: char| !character.is_alphanumeric())
722 .chain(intent.split(|character: char| !character.is_alphanumeric()))
723 .filter(|keyword| !keyword.is_empty())
724 .map(str::to_lowercase)
725 .collect::<Vec<_>>();
726 keywords.sort_unstable();
727 keywords.dedup();
728 keywords
729}
730
731fn is_boilerplate_line(line: &str) -> bool {
732 line.starts_with("//")
733 && !line.contains("TODO")
734 && !line.contains("FIXME")
735 && !line.contains("SAFETY")
736}
737
738#[cfg(test)]
739#[path = "context_gate_tests.rs"]
740mod tests;