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/// How many recently-touched files form the "working set" a new read is
18/// associated with for traversal (co-access) edges (#289). Small, so the signal
19/// stays local to what the agent is actively juggling.
20const TRAVERSAL_WINDOW: usize = 6;
21
22/// Recent distinct file paths (excluding `current`), most-recent first, capped
23/// to the traversal window — the working set a new read co-occurs with.
24pub(crate) fn recent_working_set(session: &SessionState, current: &str) -> Vec<String> {
25    let mut out: Vec<String> = Vec::new();
26    for f in session.files_touched.iter().rev() {
27        if f.path == current || out.contains(&f.path) {
28            continue;
29        }
30        out.push(f.path.clone());
31        if out.len() >= TRAVERSAL_WINDOW {
32            break;
33        }
34    }
35    out
36}
37
38/// Whether `root` is a usable project root for repo-relative normalization.
39pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
40    root.filter(|r| !r.trim().is_empty() && *r != ".")
41}
42
43/// Record a file-read operation with full Context OS side effects.
44pub fn record_file_read(
45    path: &str,
46    mode: &str,
47    original_tokens: usize,
48    output_tokens: usize,
49    is_cache_hit: bool,
50) {
51    let saved = original_tokens.saturating_sub(output_tokens);
52    let tool_key = format!("cli_{mode}");
53
54    stats::record(&tool_key, original_tokens, output_tokens);
55    heatmap::record_file_access(path, original_tokens, saved);
56
57    if let Some(mut session) = SessionState::load_latest() {
58        session.touch_file(path, None, mode, original_tokens);
59        if is_cache_hit {
60            session.record_cache_hit();
61        }
62
63        if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
64            let touched: Vec<String> = session
65                .files_touched
66                .iter()
67                .map(|ft| ft.path.clone())
68                .collect();
69            let inferred = StructuredIntent::from_file_patterns(&touched);
70            if inferred.confidence >= 0.4 {
71                session.active_structured_intent = Some(inferred);
72            }
73        }
74
75        let project_root = session.project_root.clone();
76        let calls = session.stats.total_tool_calls;
77
78        // Traversal edges: associate this read with the recent working set so the
79        // graph learns the files this task actually touches together (#289).
80        let working_set = recent_working_set(&session, path);
81
82        let _ = session.save();
83
84        if let Some(root) = usable_root(project_root.as_deref()) {
85            crate::core::cooccurrence::record_focus_access(root, path, &working_set);
86        }
87        maybe_consolidate(project_root.as_deref(), calls);
88    }
89
90    // Only real files belong in the context ledger (GL #512): directory
91    // overviews and synthetic paths would show up as "files" in the pressure
92    // table with eviction/pin semantics that make no sense for them.
93    if std::path::Path::new(path).is_file() {
94        let mut ledger = ContextLedger::load();
95        ledger.record(path, mode, original_tokens, output_tokens);
96        ledger.save();
97    }
98}
99
100/// Record a search/grep operation with full Context OS side effects.
101///
102/// `modeled_baseline` (native-tool estimate, GL #479 D1) feeds the estimated
103/// stats series; `observed_tokens` (raw measured match lines, no factor) feeds
104/// the verified ledger (GL #479 D2).
105pub fn record_search(modeled_baseline: usize, observed_tokens: usize, output_tokens: usize) {
106    stats::record("cli_grep", modeled_baseline, output_tokens);
107    crate::core::savings_ledger::record_tool_event("cli_grep", observed_tokens, output_tokens);
108
109    if let Some(mut session) = SessionState::load_latest() {
110        session.record_command();
111        let project_root = session.project_root.clone();
112        let calls = session.stats.total_tool_calls;
113        let _ = session.save();
114
115        maybe_consolidate(project_root.as_deref(), calls);
116    }
117}
118
119/// Record a tree/ls operation with full Context OS side effects.
120pub fn record_tree(original_tokens: usize, output_tokens: usize) {
121    stats::record("cli_ls", original_tokens, output_tokens);
122
123    if let Some(mut session) = SessionState::load_latest() {
124        session.record_command();
125        let _ = session.save();
126    }
127}
128
129/// Record a shell command with full Context OS side effects.
130/// Always records in stats (even for track-only 0-token calls) so the dashboard
131/// command counter stays accurate. Adding 0 tokens does not inflate savings.
132pub fn record_shell_command(original_tokens: usize, output_tokens: usize) {
133    stats::record("cli_shell", original_tokens, output_tokens);
134    // Shell compression is *measured* (raw output vs sent output), so it belongs
135    // in the verified ledger too (GL #479 D2). Zero-saving calls are skipped.
136    crate::core::savings_ledger::record_tool_event("cli_shell", original_tokens, output_tokens);
137
138    if let Some(mut session) = SessionState::load_latest() {
139        session.record_command();
140        let project_root = session.project_root.clone();
141        let calls = session.stats.total_tool_calls;
142        let _ = session.save();
143
144        if original_tokens > 0 {
145            maybe_consolidate(project_root.as_deref(), calls);
146        }
147    }
148}
149
150// TODO(arch): crate::tools::autonomy is still referenced here. Move AutonomyState
151// and should_auto_consolidate to core::autonomy_drivers for a clean layer boundary.
152fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
153    let Some(root) = project_root else { return };
154    let autonomy = crate::tools::autonomy::AutonomyState::new();
155    if crate::tools::autonomy::should_auto_consolidate(&autonomy, calls) {
156        let root = root.to_string();
157        let _ = crate::core::consolidation_engine::consolidate_latest(
158            &root,
159            crate::core::consolidation_engine::ConsolidationBudgets::default(),
160        );
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn record_file_read_does_not_panic_without_session() {
170        record_file_read("/tmp/nonexistent.rs", "full", 100, 50, false);
171    }
172
173    #[test]
174    fn record_search_does_not_panic_without_session() {
175        record_search(500, 200, 150);
176    }
177
178    #[test]
179    fn record_tree_does_not_panic_without_session() {
180        record_tree(100, 80);
181    }
182
183    #[test]
184    fn record_shell_does_not_panic_without_session() {
185        record_shell_command(500, 200);
186    }
187}