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