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