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    // Verified ledger (#685): recorded explicitly now that the heatmap chokepoint
57    // no longer bundles it. This direct-CLI path (daemon off) only has o200k
58    // counts; the model-correct re-tokenization happens on the MCP read path,
59    // which holds the source text. For the default O200kBase model these are
60    // identical anyway.
61    crate::core::savings_ledger::record_read_event(original_tokens, saved);
62
63    if let Some(mut session) = SessionState::load_latest() {
64        session.touch_file(path, None, mode, original_tokens);
65        if is_cache_hit {
66            session.record_cache_hit();
67        }
68
69        if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
70            let touched: Vec<String> = session
71                .files_touched
72                .iter()
73                .map(|ft| ft.path.clone())
74                .collect();
75            let inferred = StructuredIntent::from_file_patterns(&touched);
76            if inferred.confidence >= 0.4 {
77                session.active_structured_intent = Some(inferred);
78            }
79        }
80
81        let project_root = session.project_root.clone();
82        let calls = session.stats.total_tool_calls;
83
84        // Traversal edges: associate this read with the recent working set so the
85        // graph learns the files this task actually touches together (#289).
86        let working_set = recent_working_set(&session, path);
87
88        let _ = session.save();
89
90        if let Some(root) = usable_root(project_root.as_deref()) {
91            crate::core::cooccurrence::record_focus_access(root, path, &working_set);
92        }
93        maybe_consolidate(project_root.as_deref(), calls);
94    }
95
96    // Only real files belong in the context ledger (GL #512): directory
97    // overviews and synthetic paths would show up as "files" in the pressure
98    // table with eviction/pin semantics that make no sense for them.
99    if std::path::Path::new(path).is_file() {
100        let mut ledger = ContextLedger::load();
101        ledger.record(path, mode, original_tokens, output_tokens);
102        ledger.save();
103    }
104}
105
106/// Record a search/grep operation with full Context OS side effects.
107///
108/// `modeled_baseline` (native-tool estimate, GL #479 D1) feeds the estimated
109/// stats series; `observed_tokens` (raw measured match lines, no factor) feeds
110/// the verified ledger (GL #479 D2).
111pub fn record_search(modeled_baseline: usize, observed_tokens: usize, output_tokens: usize) {
112    stats::record("cli_grep", modeled_baseline, output_tokens);
113    crate::core::savings_ledger::record_tool_event("cli_grep", observed_tokens, output_tokens);
114
115    if let Some(mut session) = SessionState::load_latest() {
116        session.record_command();
117        let project_root = session.project_root.clone();
118        let calls = session.stats.total_tool_calls;
119        let _ = session.save();
120
121        maybe_consolidate(project_root.as_deref(), calls);
122    }
123}
124
125/// Record a tree/ls operation with full Context OS side effects.
126pub fn record_tree(original_tokens: usize, output_tokens: usize) {
127    stats::record("cli_ls", original_tokens, output_tokens);
128
129    if let Some(mut session) = SessionState::load_latest() {
130        session.record_command();
131        let _ = session.save();
132    }
133}
134
135/// Record a shell command with full Context OS side effects.
136/// Always records in stats (even for track-only 0-token calls) so the dashboard
137/// command counter stays accurate. Adding 0 tokens does not inflate savings.
138pub fn record_shell_command(original_tokens: usize, output_tokens: usize) {
139    stats::record("cli_shell", original_tokens, output_tokens);
140    // Shell compression is *measured* (raw output vs sent output), so it belongs
141    // in the verified ledger too (GL #479 D2). Zero-saving calls are skipped.
142    crate::core::savings_ledger::record_tool_event("cli_shell", original_tokens, output_tokens);
143
144    if let Some(mut session) = SessionState::load_latest() {
145        session.record_command();
146        let project_root = session.project_root.clone();
147        let calls = session.stats.total_tool_calls;
148        let _ = session.save();
149
150        if original_tokens > 0 {
151            maybe_consolidate(project_root.as_deref(), calls);
152        }
153    }
154}
155
156// TODO(arch): crate::tools::autonomy is still referenced here. Move AutonomyState
157// and should_auto_consolidate to core::autonomy_drivers for a clean layer boundary.
158fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
159    let Some(root) = project_root else { return };
160    let autonomy = crate::tools::autonomy::AutonomyState::new();
161    if crate::tools::autonomy::should_auto_consolidate(&autonomy, calls) {
162        let root = root.to_string();
163        let _ = crate::core::consolidation_engine::consolidate_latest(
164            &root,
165            crate::core::consolidation_engine::ConsolidationBudgets::default(),
166        );
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn record_file_read_does_not_panic_without_session() {
176        record_file_read("/tmp/nonexistent.rs", "full", 100, 50, false);
177    }
178
179    #[test]
180    fn record_search_does_not_panic_without_session() {
181        record_search(500, 200, 150);
182    }
183
184    #[test]
185    fn record_tree_does_not_panic_without_session() {
186        record_tree(100, 80);
187    }
188
189    #[test]
190    fn record_shell_does_not_panic_without_session() {
191        record_shell_command(500, 200);
192    }
193}