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_ir::{ContextIrSourceKindV1, ContextIrV1, RecordIrInput};
12use crate::core::context_ledger::ContextLedger;
13use crate::core::heatmap;
14use crate::core::intent_engine::StructuredIntent;
15use crate::core::session::SessionState;
16use crate::core::stats;
17
18/// How many recently-touched files form the "working set" a new read is
19/// associated with for traversal (co-access) edges (#289). Small, so the signal
20/// stays local to what the agent is actively juggling.
21const TRAVERSAL_WINDOW: usize = 6;
22
23/// Recent distinct file paths (excluding `current`), most-recent first, capped
24/// to the traversal window — the working set a new read co-occurs with.
25pub(crate) fn recent_working_set(session: &SessionState, current: &str) -> Vec<String> {
26    let mut out: Vec<String> = Vec::new();
27    for f in session.files_touched.iter().rev() {
28        if f.path == current || out.contains(&f.path) {
29            continue;
30        }
31        out.push(f.path.clone());
32        if out.len() >= TRAVERSAL_WINDOW {
33            break;
34        }
35    }
36    out
37}
38
39/// Whether `root` is a usable project root for repo-relative normalization.
40pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
41    root.filter(|r| !r.trim().is_empty() && *r != ".")
42}
43
44/// First 200 chars of `text` on a UTF-8 boundary — the exact excerpt bound the
45/// MCP dispatcher applies before handing content to the IR store
46/// (`server/call_tool.rs`), kept identical so CLI- and MCP-recorded IR items are
47/// byte-compatible. `ContextIrV1::record` redacts and further caps it.
48fn ir_excerpt(text: &str) -> &str {
49    const MAX: usize = 200;
50    if text.len() <= MAX {
51        return text;
52    }
53    let mut end = MAX;
54    while end > 0 && !text.is_char_boundary(end) {
55        end -= 1;
56    }
57    &text[..end]
58}
59
60/// Record a file-read operation with full Context OS side effects.
61///
62/// `duration` and `output_excerpt` feed the Context IR lineage (#566); the MCP
63/// dispatcher records both for every tool call but the shadow-mode `lean-ctx
64/// read` subprocess used to drop them, so IR/`ctx_proof` exports were blind to
65/// compressed shadow reads.
66pub fn record_file_read(
67    path: &str,
68    mode: &str,
69    original_tokens: usize,
70    output_tokens: usize,
71    is_cache_hit: bool,
72    duration: std::time::Duration,
73    output_excerpt: &str,
74) {
75    let saved = original_tokens.saturating_sub(output_tokens);
76    let tool_key = format!("cli_{mode}");
77
78    stats::record(&tool_key, original_tokens, output_tokens);
79    heatmap::record_file_access(path, original_tokens, saved);
80    // Verified ledger (#685): recorded explicitly now that the heatmap chokepoint
81    // no longer bundles it. This direct-CLI path (daemon off) only has o200k
82    // counts; the model-correct re-tokenization happens on the MCP read path,
83    // which holds the source text. For the default O200kBase model these are
84    // identical anyway.
85    crate::core::savings_ledger::record_read_event(original_tokens, saved);
86
87    // Project root the learning sinks below are scoped to. Defaults to "." (the
88    // MCP path's `project_root_snapshot` fallback) so a rootless read still
89    // trains a global model rather than being dropped.
90    let mut learning_root = String::from(".");
91
92    if let Some(mut session) = SessionState::load_latest() {
93        session.touch_file(path, None, mode, original_tokens);
94        if is_cache_hit {
95            session.record_cache_hit();
96        }
97
98        if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
99            let touched: Vec<String> = session
100                .files_touched
101                .iter()
102                .map(|ft| ft.path.clone())
103                .collect();
104            let inferred = StructuredIntent::from_file_patterns(&touched);
105            if inferred.confidence >= 0.4 {
106                session.active_structured_intent = Some(inferred);
107            }
108        }
109
110        let project_root = session.project_root.clone();
111        if let Some(root) = usable_root(project_root.as_deref()) {
112            learning_root = root.to_string();
113        }
114        let calls = session.stats.total_tool_calls;
115
116        // Traversal edges: associate this read with the recent working set so the
117        // graph learns the files this task actually touches together (#289).
118        let working_set = recent_working_set(&session, path);
119
120        let _ = session.save();
121
122        if let Some(root) = usable_root(project_root.as_deref()) {
123            crate::core::cooccurrence::record_focus_access(root, path, &working_set);
124        }
125        maybe_consolidate(project_root.as_deref(), calls);
126    }
127
128    // Only real files belong in the context ledger (GL #512): directory
129    // overviews and synthetic paths would show up as "files" in the pressure
130    // table with eviction/pin semantics that make no sense for them.
131    if std::path::Path::new(path).is_file() {
132        let mut ledger = ContextLedger::load();
133        ledger.record(path, mode, original_tokens, output_tokens);
134        ledger.save();
135    }
136
137    // Learning sinks the MCP read path runs in a background thread but the CLI
138    // path historically skipped — the mode predictor never trained, the
139    // compression feedback loop stayed blind and dashboard anomaly signals were
140    // missing for every shadow-mode (`view`/`grep` → `lean-ctx read`) hook read
141    // (#550). Run inline: a single-shot CLI process must finish them before it
142    // flushes and exits, so the off-hot-path thread the daemon uses is moot here.
143    record_read_learning(
144        path,
145        mode,
146        original_tokens,
147        output_tokens,
148        is_cache_hit,
149        &learning_root,
150    );
151
152    // Context IR lineage (#566): the MCP dispatcher records provenance for every
153    // tool call (`server/call_tool.rs`), but the shadow-mode hook's single-shot
154    // `lean-ctx read` bypassed it. Disk-backed load→record→save persists the
155    // entry before the process exits. `mode` rides the IR `pattern` slot to match
156    // the MCP read path (which stores its `mode` arg there).
157    let mut ir = ContextIrV1::load();
158    ir.record(RecordIrInput {
159        kind: ContextIrSourceKindV1::Read,
160        tool: "ctx_read",
161        client_name: None,
162        agent_id: None,
163        path: Some(path),
164        command: None,
165        pattern: Some(mode),
166        input_tokens: original_tokens,
167        output_tokens,
168        duration,
169        content_excerpt: ir_excerpt(output_excerpt),
170    });
171    ir.save();
172}
173
174/// Replicate the MCP read path's learning side effects (`registered/ctx_read.rs`
175/// background thread) for the standalone CLI path (#550): mode-predictor
176/// training, the compression feedback outcome and the per-call anomaly metric.
177/// All three are disk-backed and therefore work from a single-shot process; the
178/// in-memory-only detectors (loop/correction) and the bounce/adaptive signals
179/// that require routing through `ctx_read::handle` are tracked separately.
180fn record_read_learning(
181    path: &str,
182    resolved_mode: &str,
183    original_tokens: usize,
184    output_tokens: usize,
185    is_cache_hit: bool,
186    project_root: &str,
187) {
188    // Mode predictor: train auto-mode selection on the realized compression
189    // density, exactly as the MCP background thread does.
190    let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
191    let density = if output_tokens > 0 {
192        original_tokens as f64 / output_tokens as f64
193    } else {
194        1.0
195    };
196    let outcome = crate::core::mode_predictor::ModeOutcome {
197        mode: resolved_mode.to_string(),
198        tokens_in: original_tokens,
199        tokens_out: output_tokens,
200        density: density.min(1.0),
201    };
202    let mut predictor = crate::core::mode_predictor::ModePredictor::new();
203    predictor.set_project_root(project_root);
204    predictor.record(sig, outcome);
205    predictor.save();
206
207    // Compression feedback: the per-language outcome the adaptive thresholds and
208    // bounce-aware tuning learn from. `total_turns`/`total_reads` are 1 — the
209    // accurate count for this single-shot invocation, not a placeholder.
210    let saved = original_tokens.saturating_sub(output_tokens);
211    let ext = std::path::Path::new(path)
212        .extension()
213        .and_then(|e| e.to_str())
214        .unwrap_or("")
215        .to_string();
216    let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(path);
217    let feedback_outcome = crate::core::feedback::CompressionOutcome {
218        session_id: format!("{}", std::process::id()),
219        language: ext,
220        entropy_threshold: thresholds.bpe_entropy,
221        jaccard_threshold: thresholds.jaccard,
222        total_turns: 1,
223        tokens_saved: saved as u64,
224        tokens_original: original_tokens as u64,
225        cache_hits: u32::from(is_cache_hit),
226        total_reads: 1,
227        // A compressed read only counts as task-completing when this extension
228        // is not in a high-bounce state (#593); unknown stays optimistic so the
229        // cold start matches the MCP path. 0.30 mirrors BOUNCE_RATE_THRESHOLD.
230        task_completed: crate::core::bounce_tracker::global()
231            .lock()
232            .ok()
233            .and_then(|bt| bt.bounce_rate_for_extension(path))
234            .is_none_or(|rate| rate < 0.30),
235        timestamp: chrono::Local::now().to_rfc3339(),
236    };
237    let mut store = crate::core::feedback::FeedbackStore::load();
238    store.project_root = Some(project_root.to_string());
239    store.record_outcome(feedback_outcome);
240
241    // Anomaly detector: the same per-call metric the MCP post-dispatch records.
242    // `save_debounced` writes on the first call of a fresh process (last-save
243    // marker starts at 0), so the single shadow read persists before exit.
244    crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
245    crate::core::anomaly::save_debounced();
246}
247
248/// Record a search/grep operation with full Context OS side effects.
249///
250/// `modeled_baseline` (native-tool estimate, GL #479 D1) feeds the estimated
251/// stats series; `observed_tokens` (raw measured match lines, no factor) feeds
252/// the verified ledger (GL #479 D2). `pattern`/`path`/`duration`/`output_excerpt`
253/// feed the Context IR lineage (#566).
254pub fn record_search(
255    modeled_baseline: usize,
256    observed_tokens: usize,
257    output_tokens: usize,
258    pattern: &str,
259    path: &str,
260    duration: std::time::Duration,
261    output_excerpt: &str,
262) {
263    stats::record("cli_grep", modeled_baseline, output_tokens);
264    crate::core::savings_ledger::record_tool_event("cli_grep", observed_tokens, output_tokens);
265
266    if let Some(mut session) = SessionState::load_latest() {
267        session.record_command();
268        let project_root = session.project_root.clone();
269        let calls = session.stats.total_tool_calls;
270        let _ = session.save();
271
272        maybe_consolidate(project_root.as_deref(), calls);
273    }
274
275    // Per-call anomaly metric, mirroring the MCP post-dispatch (#550). Missing it
276    // left dashboard signals blind to shadow-mode (`grep` → `lean-ctx grep`) hooks.
277    crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
278    crate::core::anomaly::save_debounced();
279
280    // Context IR lineage for shadow-mode `grep` → `lean-ctx grep` (#566). The
281    // raw matched-line estimate (`observed_tokens`) is the IR input so the stored
282    // compression ratio reads matches-in / sent-out.
283    let mut ir = ContextIrV1::load();
284    ir.record(RecordIrInput {
285        kind: ContextIrSourceKindV1::Search,
286        tool: "ctx_search",
287        client_name: None,
288        agent_id: None,
289        path: Some(path),
290        command: None,
291        pattern: Some(pattern),
292        input_tokens: observed_tokens,
293        output_tokens,
294        duration,
295        content_excerpt: ir_excerpt(output_excerpt),
296    });
297    ir.save();
298}
299
300/// Record a tree/ls operation with full Context OS side effects.
301pub fn record_tree(original_tokens: usize, output_tokens: usize) {
302    stats::record("cli_ls", original_tokens, output_tokens);
303
304    if let Some(mut session) = SessionState::load_latest() {
305        session.record_command();
306        let _ = session.save();
307    }
308}
309
310/// Record a shell command with full Context OS side effects.
311/// Always records in stats (even for track-only 0-token calls) so the dashboard
312/// command counter stays accurate. Adding 0 tokens does not inflate savings.
313pub fn record_shell_command(original_tokens: usize, output_tokens: usize) {
314    stats::record("cli_shell", original_tokens, output_tokens);
315    // Shell compression is *measured* (raw output vs sent output), so it belongs
316    // in the verified ledger too (GL #479 D2). Zero-saving calls are skipped.
317    crate::core::savings_ledger::record_tool_event("cli_shell", original_tokens, output_tokens);
318
319    if let Some(mut session) = SessionState::load_latest() {
320        session.record_command();
321        let project_root = session.project_root.clone();
322        let calls = session.stats.total_tool_calls;
323        let _ = session.save();
324
325        if original_tokens > 0 {
326            maybe_consolidate(project_root.as_deref(), calls);
327        }
328    }
329}
330
331/// Flush every buffered telemetry sink to disk.
332///
333/// The long-lived MCP daemon flushes these once at shutdown
334/// (`cli/dispatch/server.rs`). Single-shot CLI commands — and the shadow-mode
335/// hook subprocesses that spawn `lean-ctx read`/`grep` — exit immediately, so
336/// without this the buffered heatmap, mode-predictor, feedback and threshold
337/// writes are silently lost the moment the process ends: `lean-ctx heatmap`
338/// stays empty and `lean-ctx gain` reports nothing for compressed reads (#550).
339///
340/// Centralized so the daemon shutdown, the parent watchdog and every CLI tool
341/// command flush the *exact same* set — the historical per-arm copies had
342/// drifted (the `read` arm flushed only `stats`, the `-c` arm four sinks, the
343/// daemon nine), which is precisely how the gap went unnoticed.
344pub fn flush_all() {
345    stats::flush();
346    heatmap::flush();
347    crate::core::path_mode_memory::flush();
348    crate::core::grammar_usage::flush();
349    crate::core::auto_mode_resolver::flush_sources();
350    crate::core::edit_quality::flush();
351    crate::core::edit_metering::flush();
352    crate::core::mode_predictor::ModePredictor::flush();
353    crate::core::feedback::FeedbackStore::flush();
354    crate::core::threshold_learning::flush();
355    crate::core::litm_calibration::flush();
356}
357
358// TODO(arch): crate::tools::autonomy is still referenced here. Move AutonomyState
359// and should_auto_consolidate to core::autonomy_drivers for a clean layer boundary.
360fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
361    let Some(root) = project_root else { return };
362    let autonomy = crate::tools::autonomy::AutonomyState::new();
363    if crate::tools::autonomy::should_auto_consolidate(&autonomy, calls) {
364        let root = root.to_string();
365        let _ = crate::core::consolidation_engine::consolidate_latest(
366            &root,
367            crate::core::consolidation_engine::ConsolidationBudgets::default(),
368        );
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    // The record_* paths now drive process-global telemetry sinks (mode
377    // predictor buffer, anomaly singleton) and read the data-dir env (#550), so
378    // every test here takes the shared isolation lock to serialize that state
379    // and keep its disk writes inside a throwaway dir.
380
381    #[test]
382    fn record_file_read_does_not_panic_without_session() {
383        let _dir = crate::core::data_dir::isolated_data_dir();
384        record_file_read(
385            "/tmp/nonexistent.rs",
386            "full",
387            100,
388            50,
389            false,
390            std::time::Duration::from_millis(1),
391            "excerpt",
392        );
393    }
394
395    #[test]
396    fn record_search_does_not_panic_without_session() {
397        let _dir = crate::core::data_dir::isolated_data_dir();
398        record_search(
399            500,
400            200,
401            150,
402            "pattern",
403            "/tmp",
404            std::time::Duration::from_millis(1),
405            "matches",
406        );
407    }
408
409    #[test]
410    fn record_tree_does_not_panic_without_session() {
411        let _dir = crate::core::data_dir::isolated_data_dir();
412        record_tree(100, 80);
413    }
414
415    #[test]
416    fn record_shell_does_not_panic_without_session() {
417        let _dir = crate::core::data_dir::isolated_data_dir();
418        record_shell_command(500, 200);
419    }
420
421    #[test]
422    fn flush_all_is_idempotent_and_safe_without_state() {
423        let _dir = crate::core::data_dir::isolated_data_dir();
424        // Empty buffers: flushing must be a harmless no-op, and calling it twice
425        // (e.g. a CLI arm followed by an atexit path) must never panic.
426        flush_all();
427        flush_all();
428    }
429
430    #[test]
431    fn cli_read_persists_learning_sinks_to_disk() {
432        // #550 regression: a single-shot CLI read must leave the mode predictor,
433        // compression feedback and heatmap on disk. The daemon used to be the
434        // only path that flushed them, so shadow-mode hook reads (`view`/`grep` →
435        // `lean-ctx read`) recorded nothing and `lean-ctx heatmap` stayed empty.
436        let dir = crate::core::data_dir::isolated_data_dir();
437        let file = dir.path().join("sample.rs");
438        std::fs::write(&file, "fn main() {\n    println!(\"hi\");\n}\n").unwrap();
439        let path = file.to_string_lossy();
440
441        record_file_read(
442            &path,
443            "full",
444            1000,
445            200,
446            false,
447            std::time::Duration::from_millis(2),
448            "sample.rs [3L]\nfn main() {}",
449        );
450        flush_all();
451
452        let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
453        let state = crate::core::paths::state_dir().expect("state dir");
454        assert!(
455            data.join("mode_stats.json").exists(),
456            "mode predictor must persist after a CLI read + flush"
457        );
458        assert!(
459            state.join("feedback.json").exists(),
460            "compression feedback must persist after a CLI read + flush"
461        );
462        assert!(
463            state.join("heatmap.json").exists(),
464            "heatmap must persist after a CLI read + flush"
465        );
466    }
467
468    #[test]
469    fn cli_read_records_context_ir_lineage() {
470        // #566: the MCP dispatcher records Context IR for every tool call, but the
471        // shadow-mode `lean-ctx read` subprocess used to skip it, so IR/ctx_proof
472        // exports were blind to compressed shadow reads. A single-shot CLI read
473        // must now persist exactly one IR item (disk-backed load→record→save).
474        let dir = crate::core::data_dir::isolated_data_dir();
475        let file = dir.path().join("ir_sample.rs");
476        std::fs::write(&file, "fn main() {}\n").unwrap();
477        let path = file.to_string_lossy();
478
479        record_file_read(
480            &path,
481            "full",
482            1000,
483            200,
484            false,
485            std::time::Duration::from_millis(3),
486            "ir_sample.rs [1L]\nfn main() {}",
487        );
488
489        let ir = ContextIrV1::load();
490        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
491        let item = &ir.items[0];
492        assert_eq!(item.source.tool, "ctx_read");
493        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
494        assert!(
495            item.source
496                .path
497                .as_deref()
498                .unwrap_or("")
499                .ends_with("ir_sample.rs"),
500            "IR records the read path, got {:?}",
501            item.source.path
502        );
503        assert_eq!(item.source.pattern.as_deref(), Some("full"));
504        assert_eq!(item.input_tokens, 1000);
505        assert_eq!(item.output_tokens, 200);
506        assert!(item.duration_us > 0, "a real duration must be recorded");
507        assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
508    }
509
510    #[test]
511    fn cli_search_records_context_ir_lineage() {
512        // #566: the shadow-mode `grep` → `lean-ctx grep` path records IR too.
513        let _dir = crate::core::data_dir::isolated_data_dir();
514
515        record_search(
516            800,
517            500,
518            120,
519            "fn handle",
520            "src/",
521            std::time::Duration::from_millis(4),
522            "src/lib.rs:12: fn handle() {}",
523        );
524
525        let ir = ContextIrV1::load();
526        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
527        let item = &ir.items[0];
528        assert_eq!(item.source.tool, "ctx_search");
529        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
530        // Input is the raw matched-line estimate, not the modeled baseline.
531        assert_eq!(item.input_tokens, 500);
532        assert_eq!(item.output_tokens, 120);
533        assert!(item.duration_us > 0, "a real duration must be recorded");
534    }
535}