Skip to main content

lean_ctx/tools/
mod.rs

1use std::path::Path;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::Arc;
4use std::time::Instant;
5use tokio::sync::RwLock;
6
7use crate::core::cache::SessionCache;
8use crate::core::session::SessionState;
9
10pub mod autonomy;
11pub mod ctx_agent;
12pub mod ctx_analyze;
13pub mod ctx_architecture;
14pub mod ctx_benchmark;
15pub mod ctx_callees;
16pub mod ctx_callers;
17pub mod ctx_callgraph;
18pub mod ctx_compress;
19pub mod ctx_compress_memory;
20pub mod ctx_context;
21pub mod ctx_cost;
22pub mod ctx_dedup;
23pub mod ctx_delta;
24pub mod ctx_discover;
25pub mod ctx_edit;
26pub mod ctx_execute;
27pub mod ctx_expand;
28pub mod ctx_feedback;
29pub mod ctx_fill;
30pub mod ctx_gain;
31pub mod ctx_graph;
32pub mod ctx_graph_diagram;
33pub mod ctx_handoff;
34pub mod ctx_heatmap;
35pub mod ctx_impact;
36pub mod ctx_intent;
37pub mod ctx_knowledge;
38pub mod ctx_knowledge_relations;
39pub mod ctx_metrics;
40pub mod ctx_multi_read;
41pub mod ctx_outline;
42pub mod ctx_overview;
43pub mod ctx_pack;
44pub mod ctx_prefetch;
45pub mod ctx_preload;
46pub mod ctx_read;
47pub mod ctx_response;
48pub mod ctx_review;
49pub mod ctx_routes;
50pub mod ctx_search;
51pub mod ctx_semantic_search;
52pub mod ctx_session;
53pub mod ctx_share;
54pub mod ctx_shell;
55pub mod ctx_smart_read;
56pub mod ctx_symbol;
57pub mod ctx_task;
58pub mod ctx_tree;
59pub mod ctx_workflow;
60pub mod ctx_wrapped;
61
62const DEFAULT_CACHE_TTL_SECS: u64 = 300;
63
64struct CepComputedStats {
65    cep_score: u32,
66    cache_util: u32,
67    mode_diversity: u32,
68    compression_rate: u32,
69    total_original: u64,
70    total_compressed: u64,
71    total_saved: u64,
72    mode_counts: std::collections::HashMap<String, u64>,
73    complexity: String,
74    cache_hits: u64,
75    total_reads: u64,
76    tool_call_count: u64,
77}
78
79/// Context Reduction Protocol mode controlling output verbosity.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum CrpMode {
82    Off,
83    Compact,
84    Tdd,
85}
86
87impl CrpMode {
88    /// Reads the CRP mode from the `LEAN_CTX_CRP_MODE` environment variable.
89    pub fn from_env() -> Self {
90        match std::env::var("LEAN_CTX_CRP_MODE")
91            .unwrap_or_default()
92            .to_lowercase()
93            .as_str()
94        {
95            "off" => Self::Off,
96            "compact" => Self::Compact,
97            _ => Self::Tdd,
98        }
99    }
100
101    pub fn parse(s: &str) -> Option<Self> {
102        match s.trim().to_lowercase().as_str() {
103            "off" => Some(Self::Off),
104            "compact" => Some(Self::Compact),
105            "tdd" => Some(Self::Tdd),
106            _ => None,
107        }
108    }
109
110    /// Effective CRP mode: explicit env var wins; otherwise use active profile.
111    pub fn effective() -> Self {
112        if let Ok(v) = std::env::var("LEAN_CTX_CRP_MODE") {
113            if !v.trim().is_empty() {
114                return Self::parse(&v).unwrap_or(Self::Tdd);
115            }
116        }
117        let p = crate::core::profiles::active_profile();
118        Self::parse(&p.compression.crp_mode).unwrap_or(Self::Tdd)
119    }
120
121    /// Returns true if the mode is TDD (maximum compression).
122    pub fn is_tdd(&self) -> bool {
123        *self == Self::Tdd
124    }
125}
126
127/// Thread-safe handle to the shared file content cache.
128pub type SharedCache = Arc<RwLock<SessionCache>>;
129
130/// Central MCP server state: cache, session, metrics, and autonomy runtime.
131#[derive(Clone)]
132pub struct LeanCtxServer {
133    pub cache: SharedCache,
134    pub session: Arc<RwLock<SessionState>>,
135    pub tool_calls: Arc<RwLock<Vec<ToolCallRecord>>>,
136    pub call_count: Arc<AtomicUsize>,
137    pub cache_ttl_secs: u64,
138    pub last_call: Arc<RwLock<Instant>>,
139    pub agent_id: Arc<RwLock<Option<String>>>,
140    pub client_name: Arc<RwLock<String>>,
141    pub autonomy: Arc<autonomy::AutonomyState>,
142    pub loop_detector: Arc<RwLock<crate::core::loop_detection::LoopDetector>>,
143    pub workflow: Arc<RwLock<Option<crate::core::workflow::WorkflowRun>>>,
144    pub ledger: Arc<RwLock<crate::core::context_ledger::ContextLedger>>,
145    pub pipeline_stats: Arc<RwLock<crate::core::pipeline::PipelineStats>>,
146    startup_project_root: Option<String>,
147    startup_shell_cwd: Option<String>,
148}
149
150/// Recorded metrics for a single MCP tool invocation.
151#[derive(Clone, Debug)]
152pub struct ToolCallRecord {
153    pub tool: String,
154    pub original_tokens: usize,
155    pub saved_tokens: usize,
156    pub mode: Option<String>,
157    pub duration_ms: u64,
158    pub timestamp: String,
159}
160
161impl Default for LeanCtxServer {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl LeanCtxServer {
168    /// Creates a new server with default settings, auto-detecting the project root.
169    pub fn new() -> Self {
170        Self::new_with_project_root(None)
171    }
172
173    /// Creates a new server rooted at the given project directory.
174    pub fn new_with_project_root(project_root: Option<&str>) -> Self {
175        Self::new_with_startup(project_root, std::env::current_dir().ok().as_deref())
176    }
177
178    fn new_with_startup(project_root: Option<&str>, startup_cwd: Option<&Path>) -> Self {
179        let ttl = std::env::var("LEAN_CTX_CACHE_TTL")
180            .ok()
181            .and_then(|v| v.parse().ok())
182            .unwrap_or(DEFAULT_CACHE_TTL_SECS);
183
184        let startup = detect_startup_context(project_root, startup_cwd);
185        let mut session = if let Some(ref root) = startup.project_root {
186            SessionState::load_latest_for_project_root(root).unwrap_or_default()
187        } else {
188            SessionState::load_latest().unwrap_or_default()
189        };
190
191        if let Some(ref root) = startup.project_root {
192            session.project_root = Some(root.clone());
193        }
194        if let Some(ref cwd) = startup.shell_cwd {
195            session.shell_cwd = Some(cwd.clone());
196        }
197
198        Self {
199            cache: Arc::new(RwLock::new(SessionCache::new())),
200            session: Arc::new(RwLock::new(session)),
201            tool_calls: Arc::new(RwLock::new(Vec::new())),
202            call_count: Arc::new(AtomicUsize::new(0)),
203            cache_ttl_secs: ttl,
204            last_call: Arc::new(RwLock::new(Instant::now())),
205            agent_id: Arc::new(RwLock::new(None)),
206            client_name: Arc::new(RwLock::new(String::new())),
207            autonomy: Arc::new(autonomy::AutonomyState::new()),
208            loop_detector: Arc::new(RwLock::new(
209                crate::core::loop_detection::LoopDetector::with_config(
210                    &crate::core::config::Config::load().loop_detection,
211                ),
212            )),
213            workflow: Arc::new(RwLock::new(
214                crate::core::workflow::load_active().ok().flatten(),
215            )),
216            ledger: Arc::new(RwLock::new(
217                crate::core::context_ledger::ContextLedger::new(),
218            )),
219            pipeline_stats: Arc::new(RwLock::new(crate::core::pipeline::PipelineStats::new())),
220            startup_project_root: startup.project_root,
221            startup_shell_cwd: startup.shell_cwd,
222        }
223    }
224
225    pub fn checkpoint_interval_effective() -> usize {
226        if let Ok(v) = std::env::var("LEAN_CTX_CHECKPOINT_INTERVAL") {
227            if let Ok(parsed) = v.trim().parse::<usize>() {
228                return parsed;
229            }
230        }
231        let profile_interval = crate::core::profiles::active_profile()
232            .autonomy
233            .checkpoint_interval;
234        if profile_interval > 0 {
235            return profile_interval as usize;
236        }
237        crate::core::config::Config::load().checkpoint_interval as usize
238    }
239
240    /// Resolves a (possibly relative) tool path against the session's project_root.
241    /// Absolute paths and "." are returned as-is. Relative paths like "src/main.rs"
242    /// are joined with project_root so tools work regardless of the server's cwd.
243    pub async fn resolve_path(&self, path: &str) -> Result<String, String> {
244        let normalized = crate::hooks::normalize_tool_path(path);
245        if normalized.is_empty() || normalized == "." {
246            return Ok(normalized);
247        }
248        let p = std::path::Path::new(&normalized);
249
250        let (resolved, jail_root) = {
251            let session = self.session.read().await;
252            let jail_root = session
253                .project_root
254                .as_deref()
255                .or(session.shell_cwd.as_deref())
256                .unwrap_or(".")
257                .to_string();
258
259            let resolved = if p.is_absolute() || p.exists() {
260                std::path::PathBuf::from(&normalized)
261            } else if let Some(ref root) = session.project_root {
262                let joined = std::path::Path::new(root).join(&normalized);
263                if joined.exists() {
264                    joined
265                } else if let Some(ref cwd) = session.shell_cwd {
266                    std::path::Path::new(cwd).join(&normalized)
267                } else {
268                    std::path::Path::new(&jail_root).join(&normalized)
269                }
270            } else if let Some(ref cwd) = session.shell_cwd {
271                std::path::Path::new(cwd).join(&normalized)
272            } else {
273                std::path::Path::new(&jail_root).join(&normalized)
274            };
275
276            (resolved, jail_root)
277        };
278
279        let jail_root_path = std::path::Path::new(&jail_root);
280        let jailed = match crate::core::pathjail::jail_path(&resolved, jail_root_path) {
281            Ok(p) => p,
282            Err(e) => {
283                if p.is_absolute() {
284                    if let Some(new_root) = maybe_derive_project_root_from_absolute(&resolved) {
285                        let candidate_under_jail = resolved.starts_with(jail_root_path);
286                        let allow_reroot = if candidate_under_jail {
287                            false
288                        } else if let Some(ref trusted_root) = self.startup_project_root {
289                            std::path::Path::new(trusted_root) == new_root.as_path()
290                        } else {
291                            !has_project_marker(jail_root_path)
292                                || is_suspicious_root(jail_root_path)
293                        };
294
295                        if allow_reroot {
296                            let mut session = self.session.write().await;
297                            let new_root_str = new_root.to_string_lossy().to_string();
298                            session.project_root = Some(new_root_str.clone());
299                            session.shell_cwd = self
300                                .startup_shell_cwd
301                                .as_ref()
302                                .filter(|cwd| std::path::Path::new(cwd).starts_with(&new_root))
303                                .cloned()
304                                .or_else(|| Some(new_root_str.clone()));
305                            let _ = session.save();
306
307                            crate::core::pathjail::jail_path(&resolved, &new_root)?
308                        } else {
309                            return Err(e);
310                        }
311                    } else {
312                        return Err(e);
313                    }
314                } else {
315                    return Err(e);
316                }
317            }
318        };
319
320        Ok(crate::hooks::normalize_tool_path(
321            &jailed.to_string_lossy().replace('\\', "/"),
322        ))
323    }
324
325    /// Like `resolve_path`, but returns the original path on failure instead of an error.
326    pub async fn resolve_path_or_passthrough(&self, path: &str) -> String {
327        self.resolve_path(path)
328            .await
329            .unwrap_or_else(|_| path.to_string())
330    }
331
332    /// Clears the cache and saves the session if the TTL idle threshold has been exceeded.
333    pub async fn check_idle_expiry(&self) {
334        if self.cache_ttl_secs == 0 {
335            return;
336        }
337        let last = *self.last_call.read().await;
338        if last.elapsed().as_secs() >= self.cache_ttl_secs {
339            {
340                let mut session = self.session.write().await;
341                let _ = session.save();
342            }
343            let mut cache = self.cache.write().await;
344            let count = cache.clear();
345            if count > 0 {
346                tracing::info!(
347                    "Cache auto-cleared after {}s idle ({count} file(s))",
348                    self.cache_ttl_secs
349                );
350            }
351        }
352        *self.last_call.write().await = Instant::now();
353    }
354
355    /// Records a tool call's token savings without timing information.
356    pub async fn record_call(
357        &self,
358        tool: &str,
359        original: usize,
360        saved: usize,
361        mode: Option<String>,
362    ) {
363        self.record_call_with_timing(tool, original, saved, mode, 0)
364            .await;
365    }
366
367    /// Records a tool call like `record_call`, but includes an optional file path for observability.
368    pub async fn record_call_with_path(
369        &self,
370        tool: &str,
371        original: usize,
372        saved: usize,
373        mode: Option<String>,
374        path: Option<&str>,
375    ) {
376        self.record_call_with_timing_inner(tool, original, saved, mode, 0, path)
377            .await;
378    }
379
380    /// Records a tool call's token savings, duration, and emits events and stats.
381    pub async fn record_call_with_timing(
382        &self,
383        tool: &str,
384        original: usize,
385        saved: usize,
386        mode: Option<String>,
387        duration_ms: u64,
388    ) {
389        self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, None)
390            .await;
391    }
392
393    async fn record_call_with_timing_inner(
394        &self,
395        tool: &str,
396        original: usize,
397        saved: usize,
398        mode: Option<String>,
399        duration_ms: u64,
400        path: Option<&str>,
401    ) {
402        let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
403        let mut calls = self.tool_calls.write().await;
404        calls.push(ToolCallRecord {
405            tool: tool.to_string(),
406            original_tokens: original,
407            saved_tokens: saved,
408            mode: mode.clone(),
409            duration_ms,
410            timestamp: ts.clone(),
411        });
412
413        if duration_ms > 0 {
414            Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
415        }
416
417        crate::core::events::emit_tool_call(
418            tool,
419            original as u64,
420            saved as u64,
421            mode.clone(),
422            duration_ms,
423            path.map(ToString::to_string),
424        );
425
426        let output_tokens = original.saturating_sub(saved);
427        crate::core::stats::record(tool, original, output_tokens);
428
429        let mut session = self.session.write().await;
430        session.record_tool_call(saved as u64, original as u64);
431        if tool == "ctx_shell" {
432            session.record_command();
433        }
434        let pending_save = if session.should_save() {
435            session.prepare_save().ok()
436        } else {
437            None
438        };
439        drop(calls);
440        drop(session);
441
442        if let Some(prepared) = pending_save {
443            tokio::task::spawn_blocking(move || {
444                let _ = prepared.write_to_disk();
445            });
446        }
447
448        self.write_mcp_live_stats().await;
449    }
450
451    /// Returns true if over an hour has passed since the last tool call.
452    pub async fn is_prompt_cache_stale(&self) -> bool {
453        let last = *self.last_call.read().await;
454        last.elapsed().as_secs() > 3600
455    }
456
457    /// Promotes lightweight read modes to richer ones when the prompt cache is stale.
458    pub fn upgrade_mode_if_stale(mode: &str, stale: bool) -> &str {
459        if !stale {
460            return mode;
461        }
462        match mode {
463            "full" => "full",
464            "map" => "signatures",
465            m => m,
466        }
467    }
468
469    /// Increments the call counter and returns true if a checkpoint is due.
470    pub fn increment_and_check(&self) -> bool {
471        let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
472        let interval = Self::checkpoint_interval_effective();
473        interval > 0 && count.is_multiple_of(interval)
474    }
475
476    /// Generates a compressed context checkpoint with session state and multi-agent sync.
477    pub async fn auto_checkpoint(&self) -> Option<String> {
478        let cache = self.cache.read().await;
479        if cache.get_all_entries().is_empty() {
480            return None;
481        }
482        let complexity = crate::core::adaptive::classify_from_context(&cache);
483        let checkpoint = ctx_compress::handle(&cache, true, CrpMode::effective());
484        drop(cache);
485
486        let mut session = self.session.write().await;
487        let _ = session.save();
488        let session_summary = session.format_compact();
489        let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
490        let project_root = session.project_root.clone();
491        drop(session);
492
493        if has_insights {
494            if let Some(ref root) = project_root {
495                let root = root.clone();
496                std::thread::spawn(move || {
497                    auto_consolidate_knowledge(&root);
498                });
499            }
500        }
501
502        let multi_agent_block = self
503            .auto_multi_agent_checkpoint(project_root.as_ref())
504            .await;
505
506        self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
507            .await;
508
509        self.record_cep_snapshot().await;
510
511        Some(format!(
512            "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}",
513            complexity.instruction_suffix()
514        ))
515    }
516
517    async fn auto_multi_agent_checkpoint(&self, project_root: Option<&String>) -> String {
518        let Some(root) = project_root else {
519            return String::new();
520        };
521
522        let registry = crate::core::agents::AgentRegistry::load_or_create();
523        let active = registry.list_active(Some(root));
524        if active.len() <= 1 {
525            return String::new();
526        }
527
528        let agent_id = self.agent_id.read().await;
529        let my_id = match agent_id.as_deref() {
530            Some(id) => id.to_string(),
531            None => return String::new(),
532        };
533        drop(agent_id);
534
535        let cache = self.cache.read().await;
536        let entries = cache.get_all_entries();
537        if !entries.is_empty() {
538            let mut by_access: Vec<_> = entries.iter().collect();
539            by_access.sort_by_key(|x| std::cmp::Reverse(x.1.read_count));
540            let top_paths: Vec<&str> = by_access
541                .iter()
542                .take(5)
543                .map(|(key, _)| key.as_str())
544                .collect();
545            let paths_csv = top_paths.join(",");
546
547            let _ = ctx_share::handle("push", Some(&my_id), None, Some(&paths_csv), None, &cache);
548        }
549        drop(cache);
550
551        let pending_count = registry
552            .scratchpad
553            .iter()
554            .filter(|e| !e.read_by.contains(&my_id) && e.from_agent != my_id)
555            .count();
556
557        let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
558            .unwrap_or_default()
559            .join("agents")
560            .join("shared");
561        let shared_count = if shared_dir.exists() {
562            std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
563        } else {
564            0
565        };
566
567        let agent_names: Vec<String> = active
568            .iter()
569            .map(|a| {
570                let role = a.role.as_deref().unwrap_or(&a.agent_type);
571                format!("{role}({})", &a.agent_id[..8.min(a.agent_id.len())])
572            })
573            .collect();
574
575        format!(
576            "\n\n--- MULTI-AGENT SYNC ---\nAgents: {} | Pending msgs: {} | Shared contexts: {}\nAuto-shared top-5 cached files.\n--- END SYNC ---",
577            agent_names.join(", "),
578            pending_count,
579            shared_count,
580        )
581    }
582
583    /// Appends a tool call entry to the rotating `tool-calls.log` file.
584    pub fn append_tool_call_log(
585        tool: &str,
586        duration_ms: u64,
587        original: usize,
588        saved: usize,
589        mode: Option<&str>,
590        timestamp: &str,
591    ) {
592        const MAX_LOG_LINES: usize = 50;
593        if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
594            let log_path = dir.join("tool-calls.log");
595            let mode_str = mode.unwrap_or("-");
596            let slow = if duration_ms > 5000 { " **SLOW**" } else { "" };
597            let line = format!(
598                "{timestamp}\t{tool}\t{duration_ms}ms\torig={original}\tsaved={saved}\tmode={mode_str}{slow}\n"
599            );
600
601            let mut lines: Vec<String> = std::fs::read_to_string(&log_path)
602                .unwrap_or_default()
603                .lines()
604                .map(std::string::ToString::to_string)
605                .collect();
606
607            lines.push(line.trim_end().to_string());
608            if lines.len() > MAX_LOG_LINES {
609                lines.drain(0..lines.len() - MAX_LOG_LINES);
610            }
611
612            let _ = std::fs::write(&log_path, lines.join("\n") + "\n");
613        }
614    }
615
616    fn compute_cep_stats(
617        calls: &[ToolCallRecord],
618        stats: &crate::core::cache::CacheStats,
619        complexity: &crate::core::adaptive::TaskComplexity,
620    ) -> CepComputedStats {
621        let total_original: u64 = calls.iter().map(|c| c.original_tokens as u64).sum();
622        let total_saved: u64 = calls.iter().map(|c| c.saved_tokens as u64).sum();
623        let total_compressed = total_original.saturating_sub(total_saved);
624        let compression_rate = if total_original > 0 {
625            total_saved as f64 / total_original as f64
626        } else {
627            0.0
628        };
629
630        let modes_used: std::collections::HashSet<&str> =
631            calls.iter().filter_map(|c| c.mode.as_deref()).collect();
632        let mode_diversity = (modes_used.len() as f64 / 10.0).min(1.0);
633        let cache_util = stats.hit_rate() / 100.0;
634        let cep_score = cache_util * 0.3 + mode_diversity * 0.2 + compression_rate * 0.5;
635
636        let mut mode_counts: std::collections::HashMap<String, u64> =
637            std::collections::HashMap::new();
638        for call in calls {
639            if let Some(ref mode) = call.mode {
640                *mode_counts.entry(mode.clone()).or_insert(0) += 1;
641            }
642        }
643
644        CepComputedStats {
645            cep_score: (cep_score * 100.0).round() as u32,
646            cache_util: (cache_util * 100.0).round() as u32,
647            mode_diversity: (mode_diversity * 100.0).round() as u32,
648            compression_rate: (compression_rate * 100.0).round() as u32,
649            total_original,
650            total_compressed,
651            total_saved,
652            mode_counts,
653            complexity: format!("{complexity:?}"),
654            cache_hits: stats.cache_hits,
655            total_reads: stats.total_reads,
656            tool_call_count: calls.len() as u64,
657        }
658    }
659
660    async fn write_mcp_live_stats(&self) {
661        let count = self.call_count.load(Ordering::Relaxed);
662        if count > 1 && !count.is_multiple_of(5) {
663            return;
664        }
665
666        let cache = self.cache.read().await;
667        let calls = self.tool_calls.read().await;
668        let stats = cache.get_stats();
669        let complexity = crate::core::adaptive::classify_from_context(&cache);
670
671        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
672        let started_at = calls
673            .first()
674            .map(|c| c.timestamp.clone())
675            .unwrap_or_default();
676
677        drop(cache);
678        drop(calls);
679        let live = serde_json::json!({
680            "cep_score": cs.cep_score,
681            "cache_utilization": cs.cache_util,
682            "mode_diversity": cs.mode_diversity,
683            "compression_rate": cs.compression_rate,
684            "task_complexity": cs.complexity,
685            "files_cached": cs.total_reads,
686            "total_reads": cs.total_reads,
687            "cache_hits": cs.cache_hits,
688            "tokens_saved": cs.total_saved,
689            "tokens_original": cs.total_original,
690            "tool_calls": cs.tool_call_count,
691            "started_at": started_at,
692            "updated_at": chrono::Local::now().to_rfc3339(),
693        });
694
695        if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
696            let _ = std::fs::write(dir.join("mcp-live.json"), live.to_string());
697        }
698    }
699
700    /// Persists a CEP (Context Efficiency Protocol) score snapshot for analytics.
701    pub async fn record_cep_snapshot(&self) {
702        let cache = self.cache.read().await;
703        let calls = self.tool_calls.read().await;
704        let stats = cache.get_stats();
705        let complexity = crate::core::adaptive::classify_from_context(&cache);
706
707        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
708
709        drop(cache);
710        drop(calls);
711
712        crate::core::stats::record_cep_session(
713            cs.cep_score,
714            cs.cache_hits,
715            cs.total_reads,
716            cs.total_original,
717            cs.total_compressed,
718            &cs.mode_counts,
719            cs.tool_call_count,
720            &cs.complexity,
721        );
722    }
723}
724
725#[derive(Clone, Debug, Default)]
726struct StartupContext {
727    project_root: Option<String>,
728    shell_cwd: Option<String>,
729}
730
731/// Creates a new `LeanCtxServer` with default configuration.
732pub fn create_server() -> LeanCtxServer {
733    LeanCtxServer::new()
734}
735
736const PROJECT_ROOT_MARKERS: &[&str] = &[
737    ".git",
738    ".lean-ctx.toml",
739    "Cargo.toml",
740    "package.json",
741    "go.mod",
742    "pyproject.toml",
743    "pom.xml",
744    "build.gradle",
745    "Makefile",
746    ".planning",
747];
748
749fn has_project_marker(dir: &std::path::Path) -> bool {
750    PROJECT_ROOT_MARKERS.iter().any(|m| dir.join(m).exists())
751}
752
753fn is_suspicious_root(dir: &std::path::Path) -> bool {
754    let s = dir.to_string_lossy();
755    s.contains("/.claude")
756        || s.contains("/.codex")
757        || s.contains("\\.claude")
758        || s.contains("\\.codex")
759}
760
761fn canonicalize_path(path: &std::path::Path) -> String {
762    crate::core::pathutil::safe_canonicalize_or_self(path)
763        .to_string_lossy()
764        .to_string()
765}
766
767fn detect_startup_context(
768    explicit_project_root: Option<&str>,
769    startup_cwd: Option<&std::path::Path>,
770) -> StartupContext {
771    let shell_cwd = startup_cwd.map(canonicalize_path);
772    let project_root = explicit_project_root
773        .map(|root| canonicalize_path(std::path::Path::new(root)))
774        .or_else(|| {
775            startup_cwd
776                .and_then(maybe_derive_project_root_from_absolute)
777                .map(|p| canonicalize_path(&p))
778        });
779
780    let shell_cwd = match (shell_cwd, project_root.as_ref()) {
781        (Some(cwd), Some(root))
782            if std::path::Path::new(&cwd).starts_with(std::path::Path::new(root)) =>
783        {
784            Some(cwd)
785        }
786        (_, Some(root)) => Some(root.clone()),
787        (cwd, None) => cwd,
788    };
789
790    StartupContext {
791        project_root,
792        shell_cwd,
793    }
794}
795
796fn maybe_derive_project_root_from_absolute(abs: &std::path::Path) -> Option<std::path::PathBuf> {
797    let mut cur = if abs.is_dir() {
798        abs.to_path_buf()
799    } else {
800        abs.parent()?.to_path_buf()
801    };
802    loop {
803        if has_project_marker(&cur) {
804            return Some(crate::core::pathutil::safe_canonicalize_or_self(&cur));
805        }
806        if !cur.pop() {
807            break;
808        }
809    }
810    None
811}
812
813fn auto_consolidate_knowledge(project_root: &str) {
814    use crate::core::knowledge::ProjectKnowledge;
815    use crate::core::session::SessionState;
816
817    let Some(session) = SessionState::load_latest() else {
818        return;
819    };
820
821    if session.findings.is_empty() && session.decisions.is_empty() {
822        return;
823    }
824
825    let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
826        return;
827    };
828    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
829
830    for finding in &session.findings {
831        let key = if let Some(ref file) = finding.file {
832            if let Some(line) = finding.line {
833                format!("{file}:{line}")
834            } else {
835                file.clone()
836            }
837        } else {
838            "finding-auto".to_string()
839        };
840        knowledge.remember("finding", &key, &finding.summary, &session.id, 0.7, &policy);
841    }
842
843    for decision in &session.decisions {
844        let key = decision
845            .summary
846            .chars()
847            .take(50)
848            .collect::<String>()
849            .replace(' ', "-")
850            .to_lowercase();
851        knowledge.remember(
852            "decision",
853            &key,
854            &decision.summary,
855            &session.id,
856            0.85,
857            &policy,
858        );
859    }
860
861    let task_desc = session
862        .task
863        .as_ref()
864        .map(|t| t.description.clone())
865        .unwrap_or_default();
866
867    let summary = format!(
868        "Auto-consolidate session {}: {} — {} findings, {} decisions",
869        session.id,
870        task_desc,
871        session.findings.len(),
872        session.decisions.len()
873    );
874    knowledge.consolidate(&summary, vec![session.id.clone()], &policy);
875    let _ = knowledge.save();
876}
877
878#[cfg(test)]
879mod resolve_path_tests {
880    use super::*;
881
882    fn create_git_root(path: &std::path::Path) -> String {
883        std::fs::create_dir_all(path.join(".git")).unwrap();
884        canonicalize_path(path)
885    }
886
887    #[tokio::test]
888    async fn resolve_path_can_reroot_to_trusted_startup_root_when_session_root_is_stale() {
889        let tmp = tempfile::tempdir().unwrap();
890        let stale = tmp.path().join("stale");
891        let real = tmp.path().join("real");
892        std::fs::create_dir_all(&stale).unwrap();
893        let real_root = create_git_root(&real);
894        std::fs::write(real.join("a.txt"), "ok").unwrap();
895
896        let server = LeanCtxServer::new_with_startup(None, Some(real.as_path()));
897        {
898            let mut session = server.session.write().await;
899            session.project_root = Some(stale.to_string_lossy().to_string());
900            session.shell_cwd = Some(stale.to_string_lossy().to_string());
901        }
902
903        let out = server
904            .resolve_path(&real.join("a.txt").to_string_lossy())
905            .await
906            .unwrap();
907
908        assert!(out.ends_with("/a.txt"));
909
910        let session = server.session.read().await;
911        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
912        assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
913    }
914
915    #[tokio::test]
916    async fn resolve_path_rejects_absolute_path_outside_trusted_startup_root() {
917        let tmp = tempfile::tempdir().unwrap();
918        let stale = tmp.path().join("stale");
919        let root = tmp.path().join("root");
920        let other = tmp.path().join("other");
921        std::fs::create_dir_all(&stale).unwrap();
922        create_git_root(&root);
923        let _other_value = create_git_root(&other);
924        std::fs::write(other.join("b.txt"), "no").unwrap();
925
926        let server = LeanCtxServer::new_with_startup(None, Some(root.as_path()));
927        {
928            let mut session = server.session.write().await;
929            session.project_root = Some(stale.to_string_lossy().to_string());
930            session.shell_cwd = Some(stale.to_string_lossy().to_string());
931        }
932
933        let err = server
934            .resolve_path(&other.join("b.txt").to_string_lossy())
935            .await
936            .unwrap_err();
937        assert!(err.contains("path escapes project root"));
938
939        let session = server.session.read().await;
940        assert_eq!(
941            session.project_root.as_deref(),
942            Some(stale.to_string_lossy().as_ref())
943        );
944    }
945
946    #[tokio::test]
947    #[allow(clippy::await_holding_lock)]
948    async fn startup_prefers_workspace_scoped_session_over_global_latest() {
949        let _lock = crate::core::data_dir::test_env_lock();
950        let _data = tempfile::tempdir().unwrap();
951        let _tmp = tempfile::tempdir().unwrap();
952
953        std::env::set_var("LEAN_CTX_DATA_DIR", _data.path());
954
955        let repo_a = _tmp.path().join("repo-a");
956        let repo_b = _tmp.path().join("repo-b");
957        let root_a = create_git_root(&repo_a);
958        let root_b = create_git_root(&repo_b);
959
960        let mut session_b = SessionState::new();
961        session_b.project_root = Some(root_b.clone());
962        session_b.shell_cwd = Some(root_b.clone());
963        session_b.set_task("repo-b task", None);
964        session_b.save().unwrap();
965
966        std::thread::sleep(std::time::Duration::from_millis(50));
967
968        let mut session_a = SessionState::new();
969        session_a.project_root = Some(root_a.clone());
970        session_a.shell_cwd = Some(root_a.clone());
971        session_a.set_task("repo-a latest task", None);
972        session_a.save().unwrap();
973
974        let server = LeanCtxServer::new_with_startup(None, Some(repo_b.as_path()));
975        std::env::remove_var("LEAN_CTX_DATA_DIR");
976
977        let session = server.session.read().await;
978        assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
979        assert_eq!(session.shell_cwd.as_deref(), Some(root_b.as_str()));
980        assert_eq!(
981            session.task.as_ref().map(|t| t.description.as_str()),
982            Some("repo-b task")
983        );
984    }
985
986    #[tokio::test]
987    #[allow(clippy::await_holding_lock)]
988    async fn startup_creates_fresh_session_for_new_workspace_and_preserves_subdir_cwd() {
989        let _lock = crate::core::data_dir::test_env_lock();
990        let _data = tempfile::tempdir().unwrap();
991        let _tmp = tempfile::tempdir().unwrap();
992
993        std::env::set_var("LEAN_CTX_DATA_DIR", _data.path());
994
995        let repo_a = _tmp.path().join("repo-a");
996        let repo_b = _tmp.path().join("repo-b");
997        let repo_b_src = repo_b.join("src");
998        let root_a = create_git_root(&repo_a);
999        let root_b = create_git_root(&repo_b);
1000        std::fs::create_dir_all(&repo_b_src).unwrap();
1001        let repo_b_src_value = canonicalize_path(&repo_b_src);
1002
1003        let mut session_a = SessionState::new();
1004        session_a.project_root = Some(root_a.clone());
1005        session_a.shell_cwd = Some(root_a.clone());
1006        session_a.set_task("repo-a latest task", None);
1007        let old_id = session_a.id.clone();
1008        session_a.save().unwrap();
1009
1010        let server = LeanCtxServer::new_with_startup(None, Some(repo_b_src.as_path()));
1011        std::env::remove_var("LEAN_CTX_DATA_DIR");
1012
1013        let session = server.session.read().await;
1014        assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
1015        assert_eq!(
1016            session.shell_cwd.as_deref(),
1017            Some(repo_b_src_value.as_str())
1018        );
1019        assert!(session.task.is_none());
1020        assert_ne!(session.id, old_id);
1021    }
1022
1023    #[tokio::test]
1024    async fn resolve_path_does_not_auto_update_when_current_root_is_real_project() {
1025        let tmp = tempfile::tempdir().unwrap();
1026        let root = tmp.path().join("root");
1027        let other = tmp.path().join("other");
1028        let root_value = create_git_root(&root);
1029        create_git_root(&other);
1030        std::fs::write(other.join("b.txt"), "no").unwrap();
1031
1032        let root_str = root.to_string_lossy().to_string();
1033        let server = LeanCtxServer::new_with_project_root(Some(&root_str));
1034
1035        let err = server
1036            .resolve_path(&other.join("b.txt").to_string_lossy())
1037            .await
1038            .unwrap_err();
1039        assert!(err.contains("path escapes project root"));
1040
1041        let session = server.session.read().await;
1042        assert_eq!(session.project_root.as_deref(), Some(root_value.as_str()));
1043    }
1044}