Skip to main content

lean_ctx/tools/
mod.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2use std::sync::Arc;
3use std::time::Instant;
4use tokio::sync::RwLock;
5
6use crate::core::cache::SessionCache;
7use crate::core::session::SessionState;
8
9pub mod autonomy;
10pub mod ctx_agent;
11pub mod ctx_analyze;
12pub mod ctx_benchmark;
13pub mod ctx_callees;
14pub mod ctx_callers;
15pub mod ctx_compress;
16pub mod ctx_compress_memory;
17pub mod ctx_context;
18pub mod ctx_dedup;
19pub mod ctx_delta;
20pub mod ctx_discover;
21pub mod ctx_edit;
22pub mod ctx_execute;
23pub mod ctx_fill;
24pub mod ctx_graph;
25pub mod ctx_graph_diagram;
26pub mod ctx_intent;
27pub mod ctx_knowledge;
28pub mod ctx_metrics;
29pub mod ctx_multi_read;
30pub mod ctx_outline;
31pub mod ctx_overview;
32pub mod ctx_preload;
33pub mod ctx_read;
34pub mod ctx_response;
35pub mod ctx_routes;
36pub mod ctx_search;
37pub mod ctx_semantic_search;
38pub mod ctx_session;
39pub mod ctx_share;
40pub mod ctx_shell;
41pub mod ctx_smart_read;
42pub mod ctx_symbol;
43pub mod ctx_tree;
44pub mod ctx_wrapped;
45
46const DEFAULT_CACHE_TTL_SECS: u64 = 300;
47
48struct CepComputedStats {
49    cep_score: u32,
50    cache_util: u32,
51    mode_diversity: u32,
52    compression_rate: u32,
53    total_original: u64,
54    total_compressed: u64,
55    total_saved: u64,
56    mode_counts: std::collections::HashMap<String, u64>,
57    complexity: String,
58    cache_hits: u64,
59    total_reads: u64,
60    tool_call_count: u64,
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum CrpMode {
65    Off,
66    Compact,
67    Tdd,
68}
69
70impl CrpMode {
71    pub fn from_env() -> Self {
72        match std::env::var("LEAN_CTX_CRP_MODE")
73            .unwrap_or_default()
74            .to_lowercase()
75            .as_str()
76        {
77            "off" => Self::Off,
78            "compact" => Self::Compact,
79            _ => Self::Tdd,
80        }
81    }
82
83    pub fn is_tdd(&self) -> bool {
84        *self == Self::Tdd
85    }
86}
87
88pub type SharedCache = Arc<RwLock<SessionCache>>;
89
90#[derive(Clone)]
91pub struct LeanCtxServer {
92    pub cache: SharedCache,
93    pub session: Arc<RwLock<SessionState>>,
94    pub tool_calls: Arc<RwLock<Vec<ToolCallRecord>>>,
95    pub call_count: Arc<AtomicUsize>,
96    pub checkpoint_interval: usize,
97    pub cache_ttl_secs: u64,
98    pub last_call: Arc<RwLock<Instant>>,
99    pub crp_mode: CrpMode,
100    pub agent_id: Arc<RwLock<Option<String>>>,
101    pub client_name: Arc<RwLock<String>>,
102    pub autonomy: Arc<autonomy::AutonomyState>,
103    pub loop_detector: Arc<RwLock<crate::core::loop_detection::LoopDetector>>,
104}
105
106#[derive(Clone, Debug)]
107pub struct ToolCallRecord {
108    pub tool: String,
109    pub original_tokens: usize,
110    pub saved_tokens: usize,
111    pub mode: Option<String>,
112    pub duration_ms: u64,
113    pub timestamp: String,
114}
115
116impl Default for LeanCtxServer {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl LeanCtxServer {
123    pub fn new() -> Self {
124        let config = crate::core::config::Config::load();
125
126        let interval = std::env::var("LEAN_CTX_CHECKPOINT_INTERVAL")
127            .ok()
128            .and_then(|v| v.parse().ok())
129            .unwrap_or(config.checkpoint_interval as usize);
130
131        let ttl = std::env::var("LEAN_CTX_CACHE_TTL")
132            .ok()
133            .and_then(|v| v.parse().ok())
134            .unwrap_or(DEFAULT_CACHE_TTL_SECS);
135
136        let crp_mode = CrpMode::from_env();
137
138        let session = SessionState::load_latest().unwrap_or_default();
139
140        Self {
141            cache: Arc::new(RwLock::new(SessionCache::new())),
142            session: Arc::new(RwLock::new(session)),
143            tool_calls: Arc::new(RwLock::new(Vec::new())),
144            call_count: Arc::new(AtomicUsize::new(0)),
145            checkpoint_interval: interval,
146            cache_ttl_secs: ttl,
147            last_call: Arc::new(RwLock::new(Instant::now())),
148            crp_mode,
149            agent_id: Arc::new(RwLock::new(None)),
150            client_name: Arc::new(RwLock::new(String::new())),
151            autonomy: Arc::new(autonomy::AutonomyState::new()),
152            loop_detector: Arc::new(RwLock::new(crate::core::loop_detection::LoopDetector::new())),
153        }
154    }
155
156    /// Resolves a (possibly relative) tool path against the session's project_root.
157    /// Absolute paths and "." are returned as-is. Relative paths like "src/main.rs"
158    /// are joined with project_root so tools work regardless of the server's cwd.
159    pub async fn resolve_path(&self, path: &str) -> String {
160        let normalized = crate::hooks::normalize_tool_path(path);
161        if normalized.is_empty() || normalized == "." {
162            return normalized;
163        }
164        let p = std::path::Path::new(&normalized);
165        if p.is_absolute() || p.exists() {
166            return normalized;
167        }
168        let session = self.session.read().await;
169        if let Some(ref root) = session.project_root {
170            let resolved = std::path::Path::new(root).join(&normalized);
171            if resolved.exists() {
172                return resolved.to_string_lossy().to_string();
173            }
174        }
175        if let Some(ref cwd) = session.shell_cwd {
176            let resolved = std::path::Path::new(cwd).join(&normalized);
177            if resolved.exists() {
178                return resolved.to_string_lossy().to_string();
179            }
180        }
181        normalized
182    }
183
184    pub async fn check_idle_expiry(&self) {
185        if self.cache_ttl_secs == 0 {
186            return;
187        }
188        let last = *self.last_call.read().await;
189        if last.elapsed().as_secs() >= self.cache_ttl_secs {
190            {
191                let mut session = self.session.write().await;
192                let _ = session.save();
193            }
194            let mut cache = self.cache.write().await;
195            let count = cache.clear();
196            if count > 0 {
197                tracing::info!(
198                    "Cache auto-cleared after {}s idle ({count} file(s))",
199                    self.cache_ttl_secs
200                );
201            }
202        }
203        *self.last_call.write().await = Instant::now();
204    }
205
206    pub async fn record_call(
207        &self,
208        tool: &str,
209        original: usize,
210        saved: usize,
211        mode: Option<String>,
212    ) {
213        self.record_call_with_timing(tool, original, saved, mode, 0)
214            .await;
215    }
216
217    pub async fn record_call_with_timing(
218        &self,
219        tool: &str,
220        original: usize,
221        saved: usize,
222        mode: Option<String>,
223        duration_ms: u64,
224    ) {
225        let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
226        let mut calls = self.tool_calls.write().await;
227        calls.push(ToolCallRecord {
228            tool: tool.to_string(),
229            original_tokens: original,
230            saved_tokens: saved,
231            mode: mode.clone(),
232            duration_ms,
233            timestamp: ts.clone(),
234        });
235
236        if duration_ms > 0 {
237            Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
238        }
239
240        crate::core::events::emit_tool_call(
241            tool,
242            original as u64,
243            saved as u64,
244            mode.clone(),
245            duration_ms,
246            None,
247        );
248
249        let output_tokens = original.saturating_sub(saved);
250        crate::core::stats::record(tool, original, output_tokens);
251
252        let mut session = self.session.write().await;
253        session.record_tool_call(saved as u64, original as u64);
254        if tool == "ctx_shell" {
255            session.record_command();
256        }
257        if session.should_save() {
258            let _ = session.save();
259        }
260        drop(calls);
261        drop(session);
262
263        self.write_mcp_live_stats().await;
264    }
265
266    pub async fn is_prompt_cache_stale(&self) -> bool {
267        let last = *self.last_call.read().await;
268        last.elapsed().as_secs() > 3600
269    }
270
271    pub fn upgrade_mode_if_stale(mode: &str, stale: bool) -> &str {
272        if !stale {
273            return mode;
274        }
275        match mode {
276            "full" => "full",
277            "map" => "signatures",
278            m => m,
279        }
280    }
281
282    pub fn increment_and_check(&self) -> bool {
283        let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
284        self.checkpoint_interval > 0 && count.is_multiple_of(self.checkpoint_interval)
285    }
286
287    pub async fn auto_checkpoint(&self) -> Option<String> {
288        let cache = self.cache.read().await;
289        if cache.get_all_entries().is_empty() {
290            return None;
291        }
292        let complexity = crate::core::adaptive::classify_from_context(&cache);
293        let checkpoint = ctx_compress::handle(&cache, true, self.crp_mode);
294        drop(cache);
295
296        let mut session = self.session.write().await;
297        let _ = session.save();
298        let session_summary = session.format_compact();
299        let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
300        let project_root = session.project_root.clone();
301        drop(session);
302
303        if has_insights {
304            if let Some(ref root) = project_root {
305                let root = root.clone();
306                std::thread::spawn(move || {
307                    auto_consolidate_knowledge(&root);
308                });
309            }
310        }
311
312        let multi_agent_block = self.auto_multi_agent_checkpoint(&project_root).await;
313
314        self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
315            .await;
316
317        self.record_cep_snapshot().await;
318
319        Some(format!(
320            "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}",
321            complexity.instruction_suffix()
322        ))
323    }
324
325    async fn auto_multi_agent_checkpoint(&self, project_root: &Option<String>) -> String {
326        let root = match project_root {
327            Some(r) => r,
328            None => return String::new(),
329        };
330
331        let registry = crate::core::agents::AgentRegistry::load_or_create();
332        let active = registry.list_active(Some(root));
333        if active.len() <= 1 {
334            return String::new();
335        }
336
337        let agent_id = self.agent_id.read().await;
338        let my_id = match agent_id.as_deref() {
339            Some(id) => id.to_string(),
340            None => return String::new(),
341        };
342        drop(agent_id);
343
344        let cache = self.cache.read().await;
345        let entries = cache.get_all_entries();
346        if !entries.is_empty() {
347            let mut by_access: Vec<_> = entries.iter().collect();
348            by_access.sort_by(|a, b| b.1.read_count.cmp(&a.1.read_count));
349            let top_paths: Vec<&str> = by_access
350                .iter()
351                .take(5)
352                .map(|(key, _)| key.as_str())
353                .collect();
354            let paths_csv = top_paths.join(",");
355
356            let _ = ctx_share::handle("push", Some(&my_id), None, Some(&paths_csv), None, &cache);
357        }
358        drop(cache);
359
360        let pending_count = registry
361            .scratchpad
362            .iter()
363            .filter(|e| !e.read_by.contains(&my_id) && e.from_agent != my_id)
364            .count();
365
366        let shared_dir = dirs::home_dir()
367            .unwrap_or_default()
368            .join(".lean-ctx")
369            .join("agents")
370            .join("shared");
371        let shared_count = if shared_dir.exists() {
372            std::fs::read_dir(&shared_dir)
373                .map(|rd| rd.count())
374                .unwrap_or(0)
375        } else {
376            0
377        };
378
379        let agent_names: Vec<String> = active
380            .iter()
381            .map(|a| {
382                let role = a.role.as_deref().unwrap_or(&a.agent_type);
383                format!("{role}({})", &a.agent_id[..8.min(a.agent_id.len())])
384            })
385            .collect();
386
387        format!(
388            "\n\n--- MULTI-AGENT SYNC ---\nAgents: {} | Pending msgs: {} | Shared contexts: {}\nAuto-shared top-5 cached files.\n--- END SYNC ---",
389            agent_names.join(", "),
390            pending_count,
391            shared_count,
392        )
393    }
394
395    pub fn append_tool_call_log(
396        tool: &str,
397        duration_ms: u64,
398        original: usize,
399        saved: usize,
400        mode: Option<&str>,
401        timestamp: &str,
402    ) {
403        const MAX_LOG_LINES: usize = 50;
404        if let Some(dir) = dirs::home_dir().map(|h| h.join(".lean-ctx")) {
405            let log_path = dir.join("tool-calls.log");
406            let mode_str = mode.unwrap_or("-");
407            let slow = if duration_ms > 5000 { " **SLOW**" } else { "" };
408            let line = format!(
409                "{timestamp}\t{tool}\t{duration_ms}ms\torig={original}\tsaved={saved}\tmode={mode_str}{slow}\n"
410            );
411
412            let mut lines: Vec<String> = std::fs::read_to_string(&log_path)
413                .unwrap_or_default()
414                .lines()
415                .map(|l| l.to_string())
416                .collect();
417
418            lines.push(line.trim_end().to_string());
419            if lines.len() > MAX_LOG_LINES {
420                lines.drain(0..lines.len() - MAX_LOG_LINES);
421            }
422
423            let _ = std::fs::write(&log_path, lines.join("\n") + "\n");
424        }
425    }
426
427    fn compute_cep_stats(
428        calls: &[ToolCallRecord],
429        stats: &crate::core::cache::CacheStats,
430        complexity: &crate::core::adaptive::TaskComplexity,
431    ) -> CepComputedStats {
432        let total_original: u64 = calls.iter().map(|c| c.original_tokens as u64).sum();
433        let total_saved: u64 = calls.iter().map(|c| c.saved_tokens as u64).sum();
434        let total_compressed = total_original.saturating_sub(total_saved);
435        let compression_rate = if total_original > 0 {
436            total_saved as f64 / total_original as f64
437        } else {
438            0.0
439        };
440
441        let modes_used: std::collections::HashSet<&str> =
442            calls.iter().filter_map(|c| c.mode.as_deref()).collect();
443        let mode_diversity = (modes_used.len() as f64 / 6.0).min(1.0);
444        let cache_util = stats.hit_rate() / 100.0;
445        let cep_score = cache_util * 0.3 + mode_diversity * 0.2 + compression_rate * 0.5;
446
447        let mut mode_counts: std::collections::HashMap<String, u64> =
448            std::collections::HashMap::new();
449        for call in calls {
450            if let Some(ref mode) = call.mode {
451                *mode_counts.entry(mode.clone()).or_insert(0) += 1;
452            }
453        }
454
455        CepComputedStats {
456            cep_score: (cep_score * 100.0).round() as u32,
457            cache_util: (cache_util * 100.0).round() as u32,
458            mode_diversity: (mode_diversity * 100.0).round() as u32,
459            compression_rate: (compression_rate * 100.0).round() as u32,
460            total_original,
461            total_compressed,
462            total_saved,
463            mode_counts,
464            complexity: format!("{:?}", complexity),
465            cache_hits: stats.cache_hits,
466            total_reads: stats.total_reads,
467            tool_call_count: calls.len() as u64,
468        }
469    }
470
471    async fn write_mcp_live_stats(&self) {
472        let cache = self.cache.read().await;
473        let calls = self.tool_calls.read().await;
474        let stats = cache.get_stats();
475        let complexity = crate::core::adaptive::classify_from_context(&cache);
476
477        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
478
479        drop(cache);
480        drop(calls);
481
482        let live = serde_json::json!({
483            "cep_score": cs.cep_score,
484            "cache_utilization": cs.cache_util,
485            "mode_diversity": cs.mode_diversity,
486            "compression_rate": cs.compression_rate,
487            "task_complexity": cs.complexity,
488            "files_cached": cs.total_reads,
489            "total_reads": cs.total_reads,
490            "cache_hits": cs.cache_hits,
491            "tokens_saved": cs.total_saved,
492            "tokens_original": cs.total_original,
493            "tool_calls": cs.tool_call_count,
494            "updated_at": chrono::Local::now().to_rfc3339(),
495        });
496
497        if let Some(dir) = dirs::home_dir().map(|h| h.join(".lean-ctx")) {
498            let _ = std::fs::write(dir.join("mcp-live.json"), live.to_string());
499        }
500    }
501
502    pub async fn record_cep_snapshot(&self) {
503        let cache = self.cache.read().await;
504        let calls = self.tool_calls.read().await;
505        let stats = cache.get_stats();
506        let complexity = crate::core::adaptive::classify_from_context(&cache);
507
508        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
509
510        drop(cache);
511        drop(calls);
512
513        crate::core::stats::record_cep_session(
514            cs.cep_score,
515            cs.cache_hits,
516            cs.total_reads,
517            cs.total_original,
518            cs.total_compressed,
519            &cs.mode_counts,
520            cs.tool_call_count,
521            &cs.complexity,
522        );
523    }
524}
525
526pub fn create_server() -> LeanCtxServer {
527    LeanCtxServer::new()
528}
529
530fn auto_consolidate_knowledge(project_root: &str) {
531    use crate::core::knowledge::ProjectKnowledge;
532    use crate::core::session::SessionState;
533
534    let session = match SessionState::load_latest() {
535        Some(s) => s,
536        None => return,
537    };
538
539    if session.findings.is_empty() && session.decisions.is_empty() {
540        return;
541    }
542
543    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
544
545    for finding in &session.findings {
546        let key = if let Some(ref file) = finding.file {
547            if let Some(line) = finding.line {
548                format!("{file}:{line}")
549            } else {
550                file.clone()
551            }
552        } else {
553            "finding-auto".to_string()
554        };
555        knowledge.remember("finding", &key, &finding.summary, &session.id, 0.7);
556    }
557
558    for decision in &session.decisions {
559        let key = decision
560            .summary
561            .chars()
562            .take(50)
563            .collect::<String>()
564            .replace(' ', "-")
565            .to_lowercase();
566        knowledge.remember("decision", &key, &decision.summary, &session.id, 0.85);
567    }
568
569    let task_desc = session
570        .task
571        .as_ref()
572        .map(|t| t.description.clone())
573        .unwrap_or_default();
574
575    let summary = format!(
576        "Auto-consolidate session {}: {} — {} findings, {} decisions",
577        session.id,
578        task_desc,
579        session.findings.len(),
580        session.decisions.len()
581    );
582    knowledge.consolidate(&summary, vec![session.id.clone()]);
583    let _ = knowledge.save();
584}