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 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 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}