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