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 ops: status|reset|evict. Manages persistent context pressure.",
23            json!({
24                "type": "object",
25                "properties": {
26                    "action": {
27                        "type": "string",
28                        "enum": ["status", "reset", "evict"],
29                        "description": "Ledger operation: status (show pressure), reset (clear all), evict (remove specific files)"
30                    },
31                    "targets": {
32                        "type": "string",
33                        "description": "Comma-separated file paths to evict (required for 'evict' action)"
34                    }
35                },
36                "required": ["action"]
37            }),
38        )
39    }
40
41    fn handle(
42        &self,
43        args: &Map<String, Value>,
44        ctx: &ToolContext,
45    ) -> Result<ToolOutput, ErrorData> {
46        let action = get_str(args, "action")
47            .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
48
49        let ledger_arc = ctx
50            .ledger
51            .as_ref()
52            .ok_or_else(|| ErrorData::internal_error("ledger not available", None))?;
53
54        let result = match action.as_str() {
55            "status" => {
56                let Some(ledger) =
57                    crate::server::bounded_lock::read(ledger_arc, "ctx_ledger:status")
58                else {
59                    return Ok(ToolOutput::simple(
60                        "[ledger status unavailable — busy, retry]".to_string(),
61                    ));
62                };
63                let pressure = ledger.pressure();
64                let top_files: Vec<String> = ledger
65                    .files_by_token_cost()
66                    .iter()
67                    .take(5)
68                    .map(|(path, tokens)| {
69                        format!(
70                            "  {} ({} tok)",
71                            crate::core::protocol::shorten_path(path),
72                            tokens
73                        )
74                    })
75                    .collect();
76
77                let mut lines = vec![
78                    format!(
79                        "Context pressure: {:.0}% ({}/{} tokens)",
80                        pressure.utilization * 100.0,
81                        ledger.total_tokens_sent,
82                        ledger.window_size,
83                    ),
84                    format!("Entries: {}", ledger.entries.len()),
85                    format!("Recommendation: {:?}", pressure.recommendation),
86                ];
87                if !top_files.is_empty() {
88                    lines.push("Top files by cost:".to_string());
89                    lines.extend(top_files);
90                }
91                lines.join("\n")
92            }
93
94            "reset" => {
95                let Some(mut ledger) =
96                    crate::server::bounded_lock::write(ledger_arc, "ctx_ledger:reset")
97                else {
98                    return Ok(ToolOutput::simple(
99                        "[ledger reset unavailable — busy, retry]".to_string(),
100                    ));
101                };
102                let prev_entries = ledger.entries.len();
103                let prev_tokens = ledger.total_tokens_sent;
104                ledger.reset();
105                ledger.save();
106                let flags_cleared = if let Some(cache_lock) = ctx.cache.as_ref() {
107                    match cache_lock.try_write() {
108                        Ok(mut cache) => {
109                            cache.reset_delivery_flags();
110                            true
111                        }
112                        _ => false,
113                    }
114                } else {
115                    false
116                };
117                let flag_note = if flags_cleared {
118                    " Cache delivery flags cleared."
119                } else {
120                    " Cache delivery flags: skipped (busy, use ctx_cache clear if stale)."
121                };
122                format!(
123                    "Ledger reset. Removed {prev_entries} entries, freed {prev_tokens} tracked tokens.{flag_note} Pressure: 0%."
124                )
125            }
126
127            "evict" => {
128                let targets_str = get_str(args, "targets").ok_or_else(|| {
129                    ErrorData::invalid_params(
130                        "targets is required for evict action (comma-separated paths)",
131                        None,
132                    )
133                })?;
134
135                let targets: Vec<&str> = targets_str.split(',').map(str::trim).collect();
136                if targets.is_empty() {
137                    return Ok(ToolOutput::simple(
138                        "No targets specified for eviction.".to_string(),
139                    ));
140                }
141
142                let Some(mut ledger) =
143                    crate::server::bounded_lock::write(ledger_arc, "ctx_ledger:evict")
144                else {
145                    return Ok(ToolOutput::simple(
146                        "[ledger evict unavailable — busy, retry]".to_string(),
147                    ));
148                };
149                let removed = ledger.evict_paths(&targets);
150
151                // Add exclude overlays to prevent re-accumulation
152                let root = if ctx.project_root.is_empty() {
153                    "."
154                } else {
155                    &ctx.project_root
156                };
157                let mut overlays = OverlayStore::load_project(&std::path::PathBuf::from(root));
158                for target in &targets {
159                    let item_id = ContextItemId::from_file(target);
160                    let overlay = ContextOverlay::new(
161                        item_id,
162                        OverlayOp::Exclude {
163                            reason: "evicted by ctx_ledger".into(),
164                        },
165                        OverlayScope::Session,
166                        String::new(),
167                        OverlayAuthor::Policy("ctx_ledger_evict".into()),
168                    );
169                    overlays.add(overlay);
170                }
171                let _ = overlays.save_project(&std::path::PathBuf::from(root));
172
173                ledger.save();
174
175                let pressure = ledger.pressure();
176                format!(
177                    "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
178                    targets.len(),
179                    pressure.utilization * 100.0,
180                )
181            }
182
183            _ => "Unknown action. Use: status, reset, evict".to_string(),
184        };
185
186        let changed = action != "status";
187        Ok(ToolOutput {
188            text: result,
189            original_tokens: 0,
190            saved_tokens: 0,
191            mode: Some(action),
192            path: None,
193            changed,
194            shell_outcome: None,
195        })
196    }
197}