lean_ctx/tools/registered/
ctx_ledger.rs1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::core::context_field::ContextItemId;
6use crate::core::context_overlay::{
7 ContextOverlay, OverlayAuthor, OverlayOp, OverlayScope, OverlayStore,
8};
9use crate::server::tool_trait::{get_str, McpTool, ToolContext, ToolOutput};
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 if let Ok(mut cache) = cache_lock.try_write() {
108 cache.reset_delivery_flags();
109 true
110 } else {
111 false
112 }
113 } else {
114 false
115 };
116 let flag_note = if flags_cleared {
117 " Cache delivery flags cleared."
118 } else {
119 " Cache delivery flags: skipped (busy, use ctx_cache clear if stale)."
120 };
121 format!(
122 "Ledger reset. Removed {prev_entries} entries, freed {prev_tokens} tracked tokens.{flag_note} Pressure: 0%."
123 )
124 }
125
126 "evict" => {
127 let targets_str = get_str(args, "targets").ok_or_else(|| {
128 ErrorData::invalid_params(
129 "targets is required for evict action (comma-separated paths)",
130 None,
131 )
132 })?;
133
134 let targets: Vec<&str> = targets_str.split(',').map(str::trim).collect();
135 if targets.is_empty() {
136 return Ok(ToolOutput::simple(
137 "No targets specified for eviction.".to_string(),
138 ));
139 }
140
141 let Some(mut ledger) =
142 crate::server::bounded_lock::write(ledger_arc, "ctx_ledger:evict")
143 else {
144 return Ok(ToolOutput::simple(
145 "[ledger evict unavailable — busy, retry]".to_string(),
146 ));
147 };
148 let removed = ledger.evict_paths(&targets);
149
150 let root = if ctx.project_root.is_empty() {
152 "."
153 } else {
154 &ctx.project_root
155 };
156 let mut overlays = OverlayStore::load_project(&std::path::PathBuf::from(root));
157 for target in &targets {
158 let item_id = ContextItemId::from_file(target);
159 let overlay = ContextOverlay::new(
160 item_id,
161 OverlayOp::Exclude {
162 reason: "evicted by ctx_ledger".into(),
163 },
164 OverlayScope::Session,
165 String::new(),
166 OverlayAuthor::Policy("ctx_ledger_evict".into()),
167 );
168 overlays.add(overlay);
169 }
170 let _ = overlays.save_project(&std::path::PathBuf::from(root));
171
172 ledger.save();
173
174 let pressure = ledger.pressure();
175 format!(
176 "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
177 targets.len(),
178 pressure.utilization * 100.0,
179 )
180 }
181
182 _ => "Unknown action. Use: status, reset, evict".to_string(),
183 };
184
185 let changed = action != "status";
186 Ok(ToolOutput {
187 text: result,
188 original_tokens: 0,
189 saved_tokens: 0,
190 mode: Some(action),
191 path: None,
192 changed,
193 })
194 }
195}