Skip to main content

lean_ctx/tools/registered/
ctx_ledger.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::core::context_field::ContextItemId;
6use crate::core::context_overlay::{
7    ContextOverlay, OverlayAuthor, OverlayOp, OverlayScope, OverlayStore,
8};
9use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str};
10use crate::tool_defs::tool_def;
11
12pub struct CtxLedgerTool;
13
14impl McpTool for CtxLedgerTool {
15    fn name(&self) -> &'static str {
16        "ctx_ledger"
17    }
18
19    fn tool_def(&self) -> Tool {
20        tool_def(
21            "ctx_ledger",
22            "Context ledger — track persistent context pressure.\n\
23             WORKFLOW: status → evict → reset (reset only if budget needs full flush).\n\
24             ANTI-PATTERN: don't evict files you actively need — check status first.\n\
25             Actions: status, reset, evict.",
26            json!({
27                "type": "object",
28                "properties": {
29                    "action": {
30                        "type": "string",
31                        "enum": ["status", "reset", "evict"],
32                        "description": "status|reset|evict"
33                    },
34                    "targets": {
35                        "type": "string",
36                        "description": "Paths to evict (comma-separated)"
37                    }
38                },
39                "required": ["action"]
40            }),
41        )
42    }
43
44    fn handle(
45        &self,
46        args: &Map<String, Value>,
47        ctx: &ToolContext,
48    ) -> Result<ToolOutput, ErrorData> {
49        let action = get_str(args, "action")
50            .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
51
52        let ledger_arc = ctx
53            .ledger
54            .as_ref()
55            .ok_or_else(|| ErrorData::internal_error("ledger not available", None))?;
56
57        let result = match action.as_str() {
58            "status" => {
59                let Some(ledger) =
60                    crate::server::bounded_lock::read(ledger_arc, "ctx_ledger:status")
61                else {
62                    return Ok(ToolOutput::simple(
63                        "[ledger status unavailable — busy, retry]".to_string(),
64                    ));
65                };
66                let pressure = ledger.pressure();
67                let top_files: Vec<String> = ledger
68                    .files_by_token_cost()
69                    .iter()
70                    .take(5)
71                    .map(|(path, tokens)| {
72                        format!(
73                            "  {} ({} tok)",
74                            crate::core::protocol::shorten_path(path),
75                            tokens
76                        )
77                    })
78                    .collect();
79
80                let mut lines = vec![
81                    format!(
82                        "Context pressure: {:.0}% ({}/{} tokens)",
83                        pressure.utilization * 100.0,
84                        ledger.total_tokens_sent,
85                        ledger.window_size,
86                    ),
87                    format!("Entries: {}", ledger.entries.len()),
88                    format!("Recommendation: {:?}", pressure.recommendation),
89                ];
90                if !top_files.is_empty() {
91                    lines.push("Top files by cost:".to_string());
92                    lines.extend(top_files);
93                }
94                lines.join("\n")
95            }
96
97            "reset" => {
98                let Some(mut ledger) =
99                    crate::server::bounded_lock::write(ledger_arc, "ctx_ledger:reset")
100                else {
101                    return Ok(ToolOutput::simple(
102                        "[ledger reset unavailable — busy, retry]".to_string(),
103                    ));
104                };
105                let prev_entries = ledger.entries.len();
106                let prev_tokens = ledger.total_tokens_sent;
107                ledger.reset();
108                ledger.save();
109                let flags_cleared = if let Some(cache_lock) = ctx.cache.as_ref() {
110                    match cache_lock.try_write() {
111                        Ok(mut cache) => {
112                            cache.reset_delivery_flags();
113                            true
114                        }
115                        _ => false,
116                    }
117                } else {
118                    false
119                };
120                let flag_note = if flags_cleared {
121                    " Cache delivery flags cleared."
122                } else {
123                    " Cache delivery flags: skipped (busy, use ctx_cache clear if stale)."
124                };
125                format!(
126                    "Ledger reset. Removed {prev_entries} entries, freed {prev_tokens} tracked tokens.{flag_note} Pressure: 0%."
127                )
128            }
129
130            "evict" => {
131                let targets_str = get_str(args, "targets").ok_or_else(|| {
132                    ErrorData::invalid_params(
133                        "targets is required for evict action (comma-separated paths)",
134                        None,
135                    )
136                })?;
137
138                let targets: Vec<&str> = targets_str.split(',').map(str::trim).collect();
139                if targets.is_empty() {
140                    return Ok(ToolOutput::simple(
141                        "No targets specified for eviction.".to_string(),
142                    ));
143                }
144
145                let Some(mut ledger) =
146                    crate::server::bounded_lock::write(ledger_arc, "ctx_ledger:evict")
147                else {
148                    return Ok(ToolOutput::simple(
149                        "[ledger evict unavailable — busy, retry]".to_string(),
150                    ));
151                };
152                // #715: resolve partial paths/basenames against the ledger's
153                // canonical absolute entries and report per-target outcomes.
154                let root = if ctx.project_root.is_empty() {
155                    "."
156                } else {
157                    &ctx.project_root
158                };
159                let outcomes = ledger.evict_paths_resolved(&targets, Some(root));
160                let removed = outcomes.iter().filter(|o| o.resolved.is_some()).count();
161
162                // Exclude overlays on the RESOLVED canonical paths, so the
163                // overlay actually blocks re-accumulation of the evicted file.
164                let mut overlays = OverlayStore::load_project(&std::path::PathBuf::from(root));
165                for outcome in &outcomes {
166                    let Some(resolved) = &outcome.resolved else {
167                        continue;
168                    };
169                    let item_id = ContextItemId::from_file(resolved);
170                    let overlay = ContextOverlay::new(
171                        item_id,
172                        OverlayOp::Exclude {
173                            reason: "evicted by ctx_ledger".into(),
174                        },
175                        OverlayScope::Session,
176                        String::new(),
177                        OverlayAuthor::Policy("ctx_ledger_evict".into()),
178                    );
179                    overlays.add(overlay);
180                }
181                let _ = overlays.save_project(&std::path::PathBuf::from(root));
182
183                ledger.save();
184
185                let pressure = ledger.pressure();
186                let mut lines = vec![format!(
187                    "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
188                    targets.len(),
189                    pressure.utilization * 100.0,
190                )];
191                for outcome in &outcomes {
192                    match (&outcome.resolved, outcome.ambiguous.is_empty()) {
193                        (Some(resolved), _) if resolved != &outcome.target => {
194                            lines.push(format!("  {} → {resolved}", outcome.target));
195                        }
196                        (Some(_), _) => {}
197                        (None, false) => lines.push(format!(
198                            "  {} is ambiguous ({}) — use a longer suffix",
199                            outcome.target,
200                            outcome.ambiguous.join(", ")
201                        )),
202                        (None, true) => {
203                            lines.push(format!("  {} not in ledger", outcome.target));
204                        }
205                    }
206                }
207                lines.join("\n")
208            }
209
210            _ => "Unknown action. Use: status, reset, evict".to_string(),
211        };
212
213        let changed = action != "status";
214        Ok(ToolOutput {
215            text: result,
216            original_tokens: 0,
217            saved_tokens: 0,
218            mode: Some(action),
219            path: None,
220            changed,
221            shell_outcome: None,
222        })
223    }
224}