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