Skip to main content

lean_ctx/tools/
startup.rs

1use super::server::LeanCtxServer;
2
3#[derive(Clone, Debug, Default)]
4pub(super) struct StartupContext {
5    pub(super) project_root: Option<String>,
6    pub(super) shell_cwd: Option<String>,
7}
8
9/// Creates a new `LeanCtxServer` with default configuration.
10pub fn create_server() -> LeanCtxServer {
11    LeanCtxServer::new()
12}
13
14pub(super) fn has_project_marker(dir: &std::path::Path) -> bool {
15    crate::core::pathutil::has_project_marker(dir)
16}
17
18pub(super) fn is_suspicious_root(dir: &std::path::Path) -> bool {
19    let s = dir.to_string_lossy();
20    s.contains("/.claude")
21        || s.contains("/.codex")
22        || s.contains("\\.claude")
23        || s.contains("\\.codex")
24}
25
26pub(super) fn canonicalize_path(path: &std::path::Path) -> String {
27    crate::core::pathutil::safe_canonicalize_or_self(path)
28        .to_string_lossy()
29        .to_string()
30}
31
32pub(super) fn detect_startup_context(
33    explicit_project_root: Option<&str>,
34    startup_cwd: Option<&std::path::Path>,
35) -> StartupContext {
36    let shell_cwd = startup_cwd.map(canonicalize_path);
37    let project_root = explicit_project_root
38        .map(|root| canonicalize_path(std::path::Path::new(root)))
39        .or_else(|| {
40            startup_cwd
41                .and_then(maybe_derive_project_root_from_absolute)
42                .map(|p| canonicalize_path(&p))
43        });
44
45    let shell_cwd = match (shell_cwd, project_root.as_ref()) {
46        (Some(cwd), Some(root))
47            if std::path::Path::new(&cwd).starts_with(std::path::Path::new(root)) =>
48        {
49            Some(cwd)
50        }
51        (_, Some(root)) => Some(root.clone()),
52        (cwd, None) => cwd,
53    };
54
55    StartupContext {
56        project_root,
57        shell_cwd,
58    }
59}
60
61pub(super) fn maybe_derive_project_root_from_absolute(
62    abs: &std::path::Path,
63) -> Option<std::path::PathBuf> {
64    let mut cur = if abs.is_dir() {
65        abs.to_path_buf()
66    } else {
67        abs.parent()?.to_path_buf()
68    };
69    loop {
70        if has_project_marker(&cur) {
71            return Some(crate::core::pathutil::safe_canonicalize_or_self(&cur));
72        }
73        if !cur.pop() {
74            break;
75        }
76    }
77    None
78}
79
80pub(crate) fn auto_consolidate_knowledge(project_root: &str) {
81    use crate::core::knowledge::ProjectKnowledge;
82    use crate::core::session::SessionState;
83    use chrono::Utc;
84
85    let Some(mut session) = SessionState::load_latest() else {
86        return;
87    };
88
89    let watermark = session.last_consolidate_ts;
90
91    let new_findings: Vec<_> = session
92        .findings
93        .iter()
94        .filter(|f| match watermark {
95            Some(ts) => f.timestamp > ts,
96            None => true,
97        })
98        .collect();
99
100    let new_decisions: Vec<_> = session
101        .decisions
102        .iter()
103        .filter(|d| match watermark {
104            Some(ts) => d.timestamp > ts,
105            None => true,
106        })
107        .collect();
108
109    if new_findings.is_empty() && new_decisions.is_empty() {
110        return;
111    }
112
113    let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
114        return;
115    };
116    // Load-modify-save under the shared in-process + cross-process lock so this
117    // background consolidation merges onto the latest committed facts instead of
118    // clobbering a concurrent foreground `remember`/`relate` write (issue #326).
119    let _ = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
120        for finding in &new_findings {
121            let key = if let Some(ref file) = finding.file {
122                if let Some(line) = finding.line {
123                    format!("{file}:{line}")
124                } else {
125                    file.clone()
126                }
127            } else {
128                let slug: String = finding
129                    .summary
130                    .chars()
131                    .take(60)
132                    .collect::<String>()
133                    .replace(' ', "-")
134                    .to_lowercase();
135                format!("finding-{slug}")
136            };
137            knowledge.remember("finding", &key, &finding.summary, &session.id, 0.7, &policy);
138        }
139
140        for decision in &new_decisions {
141            let key = decision
142                .summary
143                .chars()
144                .take(50)
145                .collect::<String>()
146                .replace(' ', "-")
147                .to_lowercase();
148            knowledge.remember(
149                "decision",
150                &key,
151                &decision.summary,
152                &session.id,
153                0.85,
154                &policy,
155            );
156        }
157
158        let task_desc = session
159            .task
160            .as_ref()
161            .map(|t| t.description.clone())
162            .unwrap_or_default();
163
164        let summary = format!(
165            "Auto-consolidate session {}: {} — {} findings, {} decisions",
166            session.id,
167            task_desc,
168            new_findings.len(),
169            new_decisions.len()
170        );
171        knowledge.consolidate(&summary, vec![session.id.clone()], &policy);
172    });
173
174    session.last_consolidate_ts = Some(Utc::now());
175    let _ = session.save();
176}