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