lean_ctx/tools/registered/
ctx_ledger.rs1use 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 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 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}