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                let removed = ledger.evict_paths(&targets);
153
154                // Add exclude overlays to prevent re-accumulation
155                let root = if ctx.project_root.is_empty() {
156                    "."
157                } else {
158                    &ctx.project_root
159                };
160                let mut overlays = OverlayStore::load_project(&std::path::PathBuf::from(root));
161                for target in &targets {
162                    let item_id = ContextItemId::from_file(target);
163                    let overlay = ContextOverlay::new(
164                        item_id,
165                        OverlayOp::Exclude {
166                            reason: "evicted by ctx_ledger".into(),
167                        },
168                        OverlayScope::Session,
169                        String::new(),
170                        OverlayAuthor::Policy("ctx_ledger_evict".into()),
171                    );
172                    overlays.add(overlay);
173                }
174                let _ = overlays.save_project(&std::path::PathBuf::from(root));
175
176                ledger.save();
177
178                let pressure = ledger.pressure();
179                format!(
180                    "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
181                    targets.len(),
182                    pressure.utilization * 100.0,
183                )
184            }
185
186            _ => "Unknown action. Use: status, reset, evict".to_string(),
187        };
188
189        let changed = action != "status";
190        Ok(ToolOutput {
191            text: result,
192            original_tokens: 0,
193            saved_tokens: 0,
194            mode: Some(action),
195            path: None,
196            changed,
197            shell_outcome: None,
198        })
199    }
200}