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 ledger = tokio::task::block_in_place(|| ledger_arc.blocking_read());
57 let pressure = ledger.pressure();
58 let top_files: Vec<String> = ledger
59 .files_by_token_cost()
60 .iter()
61 .take(5)
62 .map(|(path, tokens)| {
63 format!(
64 " {} ({} tok)",
65 crate::core::protocol::shorten_path(path),
66 tokens
67 )
68 })
69 .collect();
70
71 let mut lines = vec![
72 format!(
73 "Context pressure: {:.0}% ({}/{} tokens)",
74 pressure.utilization * 100.0,
75 ledger.total_tokens_sent,
76 ledger.window_size,
77 ),
78 format!("Entries: {}", ledger.entries.len()),
79 format!("Recommendation: {:?}", pressure.recommendation),
80 ];
81 if !top_files.is_empty() {
82 lines.push("Top files by cost:".to_string());
83 lines.extend(top_files);
84 }
85 lines.join("\n")
86 }
87
88 "reset" => {
89 let mut ledger = tokio::task::block_in_place(|| ledger_arc.blocking_write());
90 let prev_entries = ledger.entries.len();
91 let prev_tokens = ledger.total_tokens_sent;
92 ledger.reset();
93 ledger.save();
94 format!(
95 "Ledger reset. Removed {prev_entries} entries, freed {prev_tokens} tracked tokens. Pressure: 0%."
96 )
97 }
98
99 "evict" => {
100 let targets_str = get_str(args, "targets").ok_or_else(|| {
101 ErrorData::invalid_params(
102 "targets is required for evict action (comma-separated paths)",
103 None,
104 )
105 })?;
106
107 let targets: Vec<&str> = targets_str.split(',').map(str::trim).collect();
108 if targets.is_empty() {
109 return Ok(ToolOutput::simple(
110 "No targets specified for eviction.".to_string(),
111 ));
112 }
113
114 let mut ledger = tokio::task::block_in_place(|| ledger_arc.blocking_write());
115 let removed = ledger.evict_paths(&targets);
116
117 let root = if ctx.project_root.is_empty() {
119 "."
120 } else {
121 &ctx.project_root
122 };
123 let mut overlays = OverlayStore::load_project(&std::path::PathBuf::from(root));
124 for target in &targets {
125 let item_id = ContextItemId::from_file(target);
126 let overlay = ContextOverlay::new(
127 item_id,
128 OverlayOp::Exclude {
129 reason: "evicted by ctx_ledger".into(),
130 },
131 OverlayScope::Session,
132 String::new(),
133 OverlayAuthor::Policy("ctx_ledger_evict".into()),
134 );
135 overlays.add(overlay);
136 }
137 let _ = overlays.save_project(&std::path::PathBuf::from(root));
138
139 ledger.save();
140
141 let pressure = ledger.pressure();
142 format!(
143 "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
144 targets.len(),
145 pressure.utilization * 100.0,
146 )
147 }
148
149 _ => "Unknown action. Use: status, reset, evict".to_string(),
150 };
151
152 let changed = action != "status";
153 Ok(ToolOutput {
154 text: result,
155 original_tokens: 0,
156 saved_tokens: 0,
157 mode: Some(action),
158 path: None,
159 changed,
160 })
161 }
162}