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_smells;
64pub mod ctx_symbol;
65pub mod ctx_task;
66pub mod ctx_tree;
67pub mod ctx_verify;
68pub mod ctx_workflow;
69pub mod ctx_wrapped;
70pub(crate) mod knowledge_shared;
71pub mod registered;
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
88pub use crate::core::protocol::CrpMode;
89impl CrpMode {
93 pub fn effective() -> Self {
95 if let Ok(v) = std::env::var("LEAN_CTX_CRP_MODE") {
96 if !v.trim().is_empty() {
97 return Self::parse(&v).unwrap_or(Self::Tdd);
98 }
99 }
100 let p = crate::core::profiles::active_profile();
101 Self::parse(p.compression.crp_mode_effective()).unwrap_or(Self::Tdd)
102 }
103
104 pub fn is_tdd(&self) -> bool {
106 *self == Self::Tdd
107 }
108}
109
110pub type SharedCache = Arc<RwLock<SessionCache>>;
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum SessionMode {
115 Personal,
117 Shared,
119}
120
121#[derive(Clone)]
123pub struct LeanCtxServer {
124 pub cache: SharedCache,
125 pub session: Arc<RwLock<SessionState>>,
126 pub tool_calls: Arc<RwLock<Vec<ToolCallRecord>>>,
127 pub call_count: Arc<AtomicUsize>,
128 pub cache_ttl_secs: u64,
129 pub last_call: Arc<RwLock<Instant>>,
130 pub agent_id: Arc<RwLock<Option<String>>>,
131 pub client_name: Arc<RwLock<String>>,
132 pub autonomy: Arc<autonomy::AutonomyState>,
133 pub loop_detector: Arc<RwLock<crate::core::loop_detection::LoopDetector>>,
134 pub workflow: Arc<RwLock<Option<crate::core::workflow::WorkflowRun>>>,
135 pub ledger: Arc<RwLock<crate::core::context_ledger::ContextLedger>>,
136 pub pipeline_stats: Arc<RwLock<crate::core::pipeline::PipelineStats>>,
137 pub session_mode: SessionMode,
138 pub workspace_id: String,
139 pub channel_id: String,
140 pub context_os: Option<Arc<crate::core::context_os::ContextOsRuntime>>,
141 pub context_ir: Option<Arc<RwLock<crate::core::context_ir::ContextIrV1>>>,
142 pub registry: Option<Arc<crate::server::registry::ToolRegistry>>,
143 pub(crate) rules_stale_checked: Arc<std::sync::atomic::AtomicBool>,
144 pub(crate) last_seen_event_id: Arc<std::sync::atomic::AtomicI64>,
145 startup_project_root: Option<String>,
146 startup_shell_cwd: Option<String>,
147}
148
149pub use crate::core::protocol::ToolCallRecord;
150
151impl Default for LeanCtxServer {
152 fn default() -> Self {
153 Self::new()
154 }
155}
156
157impl LeanCtxServer {
158 pub fn new() -> Self {
160 Self::new_with_project_root(None)
161 }
162
163 pub fn new_with_project_root(project_root: Option<&str>) -> Self {
165 Self::new_with_startup(
166 project_root,
167 std::env::current_dir().ok().as_deref(),
168 SessionMode::Personal,
169 "default",
170 "default",
171 )
172 }
173
174 pub fn new_shared_with_context(
176 project_root: &str,
177 workspace_id: &str,
178 channel_id: &str,
179 ) -> Self {
180 Self::new_with_startup(
181 Some(project_root),
182 std::env::current_dir().ok().as_deref(),
183 SessionMode::Shared,
184 workspace_id,
185 channel_id,
186 )
187 }
188
189 fn new_with_startup(
190 project_root: Option<&str>,
191 startup_cwd: Option<&Path>,
192 session_mode: SessionMode,
193 workspace_id: &str,
194 channel_id: &str,
195 ) -> Self {
196 let ttl = std::env::var("LEAN_CTX_CACHE_TTL")
197 .ok()
198 .and_then(|v| v.parse().ok())
199 .unwrap_or_else(|| {
200 let cfg = crate::core::config::Config::load();
201 crate::core::config::MemoryCleanup::effective(&cfg).idle_ttl_secs()
202 });
203
204 let startup = detect_startup_context(project_root, startup_cwd);
205 let (session, context_os) = match session_mode {
206 SessionMode::Personal => {
207 let mut session = if let Some(ref root) = startup.project_root {
208 SessionState::load_latest_for_project_root(root).unwrap_or_default()
209 } else {
210 SessionState::load_latest().unwrap_or_default()
211 };
212 if let Some(ref root) = startup.project_root {
213 session.project_root = Some(root.clone());
214 }
215 if let Some(ref cwd) = startup.shell_cwd {
216 session.shell_cwd = Some(cwd.clone());
217 }
218 (Arc::new(RwLock::new(session)), None)
219 }
220 SessionMode::Shared => {
221 let Some(ref root) = startup.project_root else {
222 return Self::new_with_startup(
224 project_root,
225 startup_cwd,
226 SessionMode::Personal,
227 workspace_id,
228 channel_id,
229 );
230 };
231 let rt = crate::core::context_os::runtime();
232 let session = rt
233 .shared_sessions
234 .get_or_load(root, workspace_id, channel_id);
235 rt.metrics.record_session_loaded();
236 if let Some(ref cwd) = startup.shell_cwd {
238 if let Ok(mut s) = session.try_write() {
239 s.shell_cwd = Some(cwd.clone());
240 }
241 }
242 (session, Some(rt))
243 }
244 };
245
246 Self {
247 cache: Arc::new(RwLock::new(SessionCache::new())),
248 session,
249 tool_calls: Arc::new(RwLock::new(Vec::new())),
250 call_count: Arc::new(AtomicUsize::new(0)),
251 cache_ttl_secs: ttl,
252 last_call: Arc::new(RwLock::new(Instant::now())),
253 agent_id: Arc::new(RwLock::new(None)),
254 client_name: Arc::new(RwLock::new(String::new())),
255 autonomy: Arc::new(autonomy::AutonomyState::new()),
256 loop_detector: Arc::new(RwLock::new(
257 crate::core::loop_detection::LoopDetector::with_config(
258 &crate::core::config::Config::load().loop_detection,
259 ),
260 )),
261 workflow: Arc::new(RwLock::new(
262 crate::core::workflow::load_active().ok().flatten(),
263 )),
264 ledger: Arc::new(RwLock::new(
265 crate::core::context_ledger::ContextLedger::new(),
266 )),
267 pipeline_stats: Arc::new(RwLock::new(crate::core::pipeline::PipelineStats::new())),
268 session_mode,
269 workspace_id: if workspace_id.trim().is_empty() {
270 "default".to_string()
271 } else {
272 workspace_id.trim().to_string()
273 },
274 channel_id: if channel_id.trim().is_empty() {
275 "default".to_string()
276 } else {
277 channel_id.trim().to_string()
278 },
279 context_os,
280 context_ir: None,
281 registry: Some(std::sync::Arc::new(
282 crate::server::registry::build_registry(),
283 )),
284 rules_stale_checked: Arc::new(std::sync::atomic::AtomicBool::new(false)),
285 last_seen_event_id: Arc::new(std::sync::atomic::AtomicI64::new(0)),
286 startup_project_root: startup.project_root,
287 startup_shell_cwd: startup.shell_cwd,
288 }
289 }
290
291 pub fn checkpoint_interval_effective() -> usize {
292 if let Ok(v) = std::env::var("LEAN_CTX_CHECKPOINT_INTERVAL") {
293 if let Ok(parsed) = v.trim().parse::<usize>() {
294 return parsed;
295 }
296 }
297 let profile_interval = crate::core::profiles::active_profile()
298 .autonomy
299 .checkpoint_interval_effective();
300 if profile_interval > 0 {
301 return profile_interval as usize;
302 }
303 crate::core::config::Config::load().checkpoint_interval as usize
304 }
305
306 pub async fn resolve_path(&self, path: &str) -> Result<String, String> {
310 let normalized = crate::core::pathutil::normalize_tool_path(path);
311 if normalized.is_empty() || normalized == "." {
312 return Ok(normalized);
313 }
314 let p = std::path::Path::new(&normalized);
315
316 let (resolved, jail_root) = {
317 let session = self.session.read().await;
318 let jail_root = session
319 .project_root
320 .as_deref()
321 .or(session.shell_cwd.as_deref())
322 .unwrap_or(".")
323 .to_string();
324
325 let resolved = if p.is_absolute() || p.exists() {
326 std::path::PathBuf::from(&normalized)
327 } else if let Some(ref root) = session.project_root {
328 let joined = std::path::Path::new(root).join(&normalized);
329 if joined.exists() {
330 joined
331 } else if let Some(ref cwd) = session.shell_cwd {
332 std::path::Path::new(cwd).join(&normalized)
333 } else {
334 std::path::Path::new(&jail_root).join(&normalized)
335 }
336 } else if let Some(ref cwd) = session.shell_cwd {
337 std::path::Path::new(cwd).join(&normalized)
338 } else {
339 std::path::Path::new(&jail_root).join(&normalized)
340 };
341
342 (resolved, jail_root)
343 };
344
345 let jail_root_path = std::path::Path::new(&jail_root);
346 let jailed = match crate::core::pathjail::jail_path(&resolved, jail_root_path) {
347 Ok(p) => p,
348 Err(e) => {
349 if p.is_absolute() {
350 if let Some(new_root) = maybe_derive_project_root_from_absolute(&resolved) {
351 let candidate_under_jail = resolved.starts_with(jail_root_path);
352 let allow_reroot = if candidate_under_jail {
353 false
354 } else if let Some(ref trusted_root) = self.startup_project_root {
355 std::path::Path::new(trusted_root) == new_root.as_path()
356 } else {
357 !has_project_marker(jail_root_path)
358 || is_suspicious_root(jail_root_path)
359 };
360
361 if allow_reroot {
362 let mut session = self.session.write().await;
363 let new_root_str = new_root.to_string_lossy().to_string();
364 session.project_root = Some(new_root_str.clone());
365 session.shell_cwd = self
366 .startup_shell_cwd
367 .as_ref()
368 .filter(|cwd| std::path::Path::new(cwd).starts_with(&new_root))
369 .cloned()
370 .or_else(|| Some(new_root_str.clone()));
371 let _ = session.save();
372
373 crate::core::pathjail::jail_path(&resolved, &new_root)?
374 } else {
375 return Err(e);
376 }
377 } else {
378 return Err(e);
379 }
380 } else {
381 return Err(e);
382 }
383 }
384 };
385
386 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
387
388 Ok(crate::core::pathutil::normalize_tool_path(
389 &jailed.to_string_lossy().replace('\\', "/"),
390 ))
391 }
392
393 pub async fn resolve_path_or_passthrough(&self, path: &str) -> String {
395 self.resolve_path(path)
396 .await
397 .unwrap_or_else(|_| path.to_string())
398 }
399
400 pub async fn check_idle_expiry(&self) {
402 if self.cache_ttl_secs == 0 {
403 return;
404 }
405 let last = *self.last_call.read().await;
406 if last.elapsed().as_secs() >= self.cache_ttl_secs {
407 {
408 let mut session = self.session.write().await;
409 let _ = session.save();
410 }
411 let mut cache = self.cache.write().await;
412 let count = cache.clear();
413 if count > 0 {
414 tracing::info!(
415 "Cache auto-cleared after {}s idle ({count} file(s))",
416 self.cache_ttl_secs
417 );
418 }
419 }
420 *self.last_call.write().await = Instant::now();
421 }
422
423 pub async fn shutdown(&self) {
425 {
426 let mut session = self.session.write().await;
427 let _ = session.save();
428 }
429 {
430 let mut cache = self.cache.write().await;
431 let count = cache.clear();
432 if count > 0 {
433 tracing::info!("[shutdown] cleared {count} cached file(s)");
434 }
435 }
436 crate::core::memory_guard::force_purge();
437 }
438
439 pub async fn record_call(
441 &self,
442 tool: &str,
443 original: usize,
444 saved: usize,
445 mode: Option<String>,
446 ) {
447 self.record_call_with_timing(tool, original, saved, mode, 0)
448 .await;
449 }
450
451 pub async fn record_call_with_path(
453 &self,
454 tool: &str,
455 original: usize,
456 saved: usize,
457 mode: Option<String>,
458 path: Option<&str>,
459 ) {
460 self.record_call_with_timing_inner(tool, original, saved, mode, 0, path)
461 .await;
462 }
463
464 pub async fn record_call_with_timing(
466 &self,
467 tool: &str,
468 original: usize,
469 saved: usize,
470 mode: Option<String>,
471 duration_ms: u64,
472 ) {
473 self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, None)
474 .await;
475 }
476
477 async fn record_call_with_timing_inner(
478 &self,
479 tool: &str,
480 original: usize,
481 saved: usize,
482 mode: Option<String>,
483 duration_ms: u64,
484 path: Option<&str>,
485 ) {
486 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
487 let mut calls = self.tool_calls.write().await;
488 calls.push(ToolCallRecord {
489 tool: tool.to_string(),
490 original_tokens: original,
491 saved_tokens: saved,
492 mode: mode.clone(),
493 duration_ms,
494 timestamp: ts.clone(),
495 });
496
497 const MAX_TOOL_CALL_RECORDS: usize = 500;
498 if calls.len() > MAX_TOOL_CALL_RECORDS {
499 let excess = calls.len() - MAX_TOOL_CALL_RECORDS;
500 calls.drain(..excess);
501 }
502
503 if duration_ms > 0 {
504 Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
505 }
506
507 crate::core::events::emit_tool_call(
508 tool,
509 original as u64,
510 saved as u64,
511 mode.clone(),
512 duration_ms,
513 path.map(ToString::to_string),
514 );
515
516 let output_tokens = original.saturating_sub(saved);
517 crate::core::stats::record(tool, original, output_tokens);
518
519 let mut session = self.session.write().await;
520 session.record_tool_call(saved as u64, original as u64);
521 if tool == "ctx_shell" {
522 session.record_command();
523 }
524 let pending_save = if session.should_save() {
525 session.prepare_save().ok()
526 } else {
527 None
528 };
529 drop(calls);
530 drop(session);
531
532 if let Some(prepared) = pending_save {
533 tokio::task::spawn_blocking(move || {
534 let _ = prepared.write_to_disk();
535 });
536 }
537
538 self.write_mcp_live_stats().await;
539 }
540
541 pub async fn is_prompt_cache_stale(&self) -> bool {
543 let last = *self.last_call.read().await;
544 last.elapsed().as_secs() > 3600
545 }
546
547 pub fn upgrade_mode_if_stale(mode: &str, stale: bool) -> &str {
549 if !stale {
550 return mode;
551 }
552 match mode {
553 "full" => "full",
554 "map" => "signatures",
555 m => m,
556 }
557 }
558
559 pub fn increment_and_check(&self) -> bool {
561 let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
562 let interval = Self::checkpoint_interval_effective();
563 interval > 0 && count.is_multiple_of(interval)
564 }
565
566 pub async fn auto_checkpoint(&self) -> Option<String> {
568 let cache = self.cache.read().await;
569 if cache.get_all_entries().is_empty() {
570 return None;
571 }
572 let complexity = crate::core::adaptive::classify_from_context(&cache);
573 let checkpoint = ctx_compress::handle(&cache, false, CrpMode::effective());
574 drop(cache);
575
576 let mut session = self.session.write().await;
577 let _ = session.save();
578 let session_summary = session.format_compact();
579 let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
580 let project_root = session.project_root.clone();
581 drop(session);
582
583 if has_insights {
584 if let Some(ref root) = project_root {
585 let root = root.clone();
586 std::thread::spawn(move || {
587 auto_consolidate_knowledge(&root);
588 });
589 }
590 }
591
592 let multi_agent_block = self
593 .auto_multi_agent_checkpoint(project_root.as_ref())
594 .await;
595
596 self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
597 .await;
598
599 self.record_cep_snapshot().await;
600
601 Some(format!(
602 "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}",
603 complexity.instruction_suffix()
604 ))
605 }
606
607 async fn auto_multi_agent_checkpoint(&self, project_root: Option<&String>) -> String {
608 let Some(root) = project_root else {
609 return String::new();
610 };
611
612 let registry = crate::core::agents::AgentRegistry::load_or_create();
613 let active = registry.list_active(Some(root));
614 if active.len() <= 1 {
615 return String::new();
616 }
617
618 let agent_id = self.agent_id.read().await;
619 let my_id = match agent_id.as_deref() {
620 Some(id) => id.to_string(),
621 None => return String::new(),
622 };
623 drop(agent_id);
624
625 let cache = self.cache.read().await;
626 let entries = cache.get_all_entries();
627 if !entries.is_empty() {
628 let mut by_access: Vec<_> = entries.iter().collect();
629 by_access.sort_by_key(|x| std::cmp::Reverse(x.1.read_count));
630 let top_paths: Vec<&str> = by_access
631 .iter()
632 .take(5)
633 .map(|(key, _)| key.as_str())
634 .collect();
635 let paths_csv = top_paths.join(",");
636
637 let _ = ctx_share::handle(
638 "push",
639 Some(&my_id),
640 None,
641 Some(&paths_csv),
642 None,
643 &cache,
644 root,
645 );
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}