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 }),
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 root = if ctx.project_root.is_empty() {
155 "."
156 } else {
157 &ctx.project_root
158 };
159 let outcomes = ledger.evict_paths_resolved(&targets, Some(root));
160 let removed = outcomes.iter().filter(|o| o.resolved.is_some()).count();
161
162 let mut overlays = OverlayStore::load_project(&std::path::PathBuf::from(root));
165 for outcome in &outcomes {
166 let Some(resolved) = &outcome.resolved else {
167 continue;
168 };
169 let item_id = ContextItemId::from_file(resolved);
170 let overlay = ContextOverlay::new(
171 item_id,
172 OverlayOp::Exclude {
173 reason: "evicted by ctx_ledger".into(),
174 },
175 OverlayScope::Session,
176 String::new(),
177 OverlayAuthor::Policy("ctx_ledger_evict".into()),
178 );
179 overlays.add(overlay);
180 }
181 let _ = overlays.save_project(&std::path::PathBuf::from(root));
182
183 ledger.save();
184
185 let pressure = ledger.pressure();
186 let mut lines = vec![format!(
187 "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
188 targets.len(),
189 pressure.utilization * 100.0,
190 )];
191 for outcome in &outcomes {
192 match (&outcome.resolved, outcome.ambiguous.is_empty()) {
193 (Some(resolved), _) if resolved != &outcome.target => {
194 lines.push(format!(" {} → {resolved}", outcome.target));
195 }
196 (Some(_), _) => {}
197 (None, false) => lines.push(format!(
198 " {} is ambiguous ({}) — use a longer suffix",
199 outcome.target,
200 outcome.ambiguous.join(", ")
201 )),
202 (None, true) => {
203 lines.push(format!(" {} not in ledger", outcome.target));
204 }
205 }
206 }
207 lines.join("\n")
208 }
209
210 _ => "Unknown action. Use: status, reset, evict".to_string(),
211 };
212
213 let changed = action != "status";
214 Ok(ToolOutput {
215 text: result,
216 original_tokens: 0,
217 saved_tokens: 0,
218 mode: Some(action),
219 path: None,
220 changed,
221 shell_outcome: None,
222 })
223 }
224}