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