Skip to main content

lean_ctx/core/
tool_lifecycle.rs

1//! Shared tool lifecycle — ensures CLI and MCP paths have identical side effects.
2//!
3//! The MCP server dispatcher handles session, ledger, heatmap, intent detection,
4//! and knowledge consolidation inline (via in-memory state). When the daemon is
5//! unavailable, CLI commands call functions here to achieve the same coverage by
6//! loading/saving state from disk.
7//!
8//! NOTE: When the daemon IS running, CLI routes through `daemon_client` which
9//! calls the MCP server — these functions are NOT called in that path.
10
11use crate::core::context_ledger::ContextLedger;
12use crate::core::heatmap;
13use crate::core::intent_engine::StructuredIntent;
14use crate::core::session::SessionState;
15use crate::core::stats;
16
17/// Record a file-read operation with full Context OS side effects.
18pub fn record_file_read(
19    path: &str,
20    mode: &str,
21    original_tokens: usize,
22    output_tokens: usize,
23    is_cache_hit: bool,
24) {
25    let saved = original_tokens.saturating_sub(output_tokens);
26    let tool_key = format!("cli_{mode}");
27
28    stats::record(&tool_key, original_tokens, output_tokens);
29    heatmap::record_file_access(path, original_tokens, saved);
30
31    if let Some(mut session) = SessionState::load_latest() {
32        session.touch_file(path, None, mode, original_tokens);
33        if is_cache_hit {
34            session.record_cache_hit();
35        }
36
37        if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
38            let touched: Vec<String> = session
39                .files_touched
40                .iter()
41                .map(|ft| ft.path.clone())
42                .collect();
43            let inferred = StructuredIntent::from_file_patterns(&touched);
44            if inferred.confidence >= 0.4 {
45                session.active_structured_intent = Some(inferred);
46            }
47        }
48
49        let project_root = session.project_root.clone();
50        let calls = session.stats.total_tool_calls;
51        let _ = session.save();
52
53        maybe_consolidate(project_root.as_deref(), calls);
54    }
55
56    let mut ledger = ContextLedger::load();
57    ledger.record(path, mode, original_tokens, output_tokens);
58    ledger.save();
59}
60
61/// Record a search/grep operation with full Context OS side effects.
62pub fn record_search(original_tokens: usize, output_tokens: usize) {
63    stats::record("cli_grep", original_tokens, output_tokens);
64
65    if let Some(mut session) = SessionState::load_latest() {
66        session.record_command();
67        let project_root = session.project_root.clone();
68        let calls = session.stats.total_tool_calls;
69        let _ = session.save();
70
71        maybe_consolidate(project_root.as_deref(), calls);
72    }
73}
74
75/// Record a tree/ls operation with full Context OS side effects.
76pub fn record_tree(original_tokens: usize, output_tokens: usize) {
77    stats::record("cli_ls", original_tokens, output_tokens);
78
79    if let Some(mut session) = SessionState::load_latest() {
80        session.record_command();
81        let _ = session.save();
82    }
83}
84
85/// Record a shell command with full Context OS side effects.
86/// Always records in stats (even for track-only 0-token calls) so the dashboard
87/// command counter stays accurate. Adding 0 tokens does not inflate savings.
88pub fn record_shell_command(original_tokens: usize, output_tokens: usize) {
89    stats::record("cli_shell", original_tokens, output_tokens);
90
91    if let Some(mut session) = SessionState::load_latest() {
92        session.record_command();
93        let project_root = session.project_root.clone();
94        let calls = session.stats.total_tool_calls;
95        let _ = session.save();
96
97        if original_tokens > 0 {
98            maybe_consolidate(project_root.as_deref(), calls);
99        }
100    }
101}
102
103// TODO(arch): crate::tools::autonomy is still referenced here. Move AutonomyState
104// and should_auto_consolidate to core::autonomy_drivers for a clean layer boundary.
105fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
106    let Some(root) = project_root else { return };
107    let autonomy = crate::tools::autonomy::AutonomyState::new();
108    if crate::tools::autonomy::should_auto_consolidate(&autonomy, calls) {
109        let root = root.to_string();
110        let _ = crate::core::consolidation_engine::consolidate_latest(
111            &root,
112            crate::core::consolidation_engine::ConsolidationBudgets::default(),
113        );
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn record_file_read_does_not_panic_without_session() {
123        record_file_read("/tmp/nonexistent.rs", "full", 100, 50, false);
124    }
125
126    #[test]
127    fn record_search_does_not_panic_without_session() {
128        record_search(200, 150);
129    }
130
131    #[test]
132    fn record_tree_does_not_panic_without_session() {
133        record_tree(100, 80);
134    }
135
136    #[test]
137    fn record_shell_does_not_panic_without_session() {
138        record_shell_command(500, 200);
139    }
140}