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 removed = ledger.evict_paths(&targets);
153
154 let root = if ctx.project_root.is_empty() {
156 "."
157 } else {
158 &ctx.project_root
159 };
160 let mut overlays = OverlayStore::load_project(&std::path::PathBuf::from(root));
161 for target in &targets {
162 let item_id = ContextItemId::from_file(target);
163 let overlay = ContextOverlay::new(
164 item_id,
165 OverlayOp::Exclude {
166 reason: "evicted by ctx_ledger".into(),
167 },
168 OverlayScope::Session,
169 String::new(),
170 OverlayAuthor::Policy("ctx_ledger_evict".into()),
171 );
172 overlays.add(overlay);
173 }
174 let _ = overlays.save_project(&std::path::PathBuf::from(root));
175
176 ledger.save();
177
178 let pressure = ledger.pressure();
179 format!(
180 "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
181 targets.len(),
182 pressure.utilization * 100.0,
183 )
184 }
185
186 _ => "Unknown action. Use: status, reset, evict".to_string(),
187 };
188
189 let changed = action != "status";
190 Ok(ToolOutput {
191 text: result,
192 original_tokens: 0,
193 saved_tokens: 0,
194 mode: Some(action),
195 path: None,
196 changed,
197 shell_outcome: None,
198 })
199 }
200}