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