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