lean_ctx/server/
resources.rs1use rmcp::model::{Resource, ResourceContents};
2
3const URI_SUMMARY: &str = "lean-ctx://context/summary";
4const URI_PINNED: &str = "lean-ctx://context/pinned";
5const URI_PRESSURE: &str = "lean-ctx://context/pressure";
6const URI_PLAN: &str = "lean-ctx://context/plan";
7const URI_BOUNCE: &str = "lean-ctx://context/bounce";
8
9pub fn list_resources() -> Vec<Resource> {
10 vec![
11 make_resource(
12 URI_SUMMARY,
13 "Context Summary",
14 "Ledger compact: items, pressure, budget",
15 ),
16 make_resource(
17 URI_PINNED,
18 "Pinned Items",
19 "Pinned context items with compressed content",
20 ),
21 make_resource(
22 URI_PRESSURE,
23 "Context Pressure",
24 "Budget utilization and recommendations",
25 ),
26 make_resource(
27 URI_PLAN,
28 "Context Plan",
29 "Current context plan with modes per file",
30 ),
31 make_resource(
32 URI_BOUNCE,
33 "Bounce Stats",
34 "Bounce detection statistics and wasted tokens",
35 ),
36 ]
37}
38
39pub fn read_resource(
40 uri: &str,
41 ledger: &crate::core::context_ledger::ContextLedger,
42) -> Option<Vec<ResourceContents>> {
43 match uri {
44 URI_SUMMARY => Some(vec![ResourceContents::text(build_summary(ledger), uri)]),
45 URI_PRESSURE => Some(vec![ResourceContents::text(build_pressure(ledger), uri)]),
46 URI_PLAN => Some(vec![ResourceContents::text(build_plan(ledger), uri)]),
47 URI_PINNED => Some(vec![ResourceContents::text(build_pinned(ledger), uri)]),
48 URI_BOUNCE => Some(vec![ResourceContents::text(build_bounce(), uri)]),
49 _ => None,
50 }
51}
52
53fn make_resource(uri: &str, name: &str, desc: &str) -> Resource {
54 Resource::new(uri, name)
55 .with_description(desc)
56 .with_mime_type("text/plain")
57}
58
59fn build_summary(ledger: &crate::core::context_ledger::ContextLedger) -> String {
60 let pressure = ledger.pressure();
61 let adjusted = ledger.adjusted_total_saved();
62 format!(
63 "files:{} | sent:{} | saved:{} (adj:{}) | pressure:{:.0}% | action:{:?}",
64 ledger.entries.len(),
65 ledger.total_tokens_sent,
66 ledger.total_tokens_saved,
67 adjusted,
68 pressure.utilization * 100.0,
69 pressure.recommendation,
70 )
71}
72
73fn build_pressure(ledger: &crate::core::context_ledger::ContextLedger) -> String {
74 let p = ledger.pressure();
75 let mut lines = vec![
76 format!("utilization: {:.1}%", p.utilization * 100.0),
77 format!("remaining: {} tokens", p.remaining_tokens),
78 format!("entries: {}", p.entries_count),
79 format!("action: {:?}", p.recommendation),
80 ];
81
82 if p.utilization > 0.8 {
83 let evict = ledger.eviction_candidates_by_phi(3);
84 if !evict.is_empty() {
85 let names: Vec<_> = evict
86 .iter()
87 .take(5)
88 .map(|p| crate::core::protocol::shorten_path(p))
89 .collect();
90 lines.push(format!("eviction_candidates: {}", names.join(", ")));
91 }
92 }
93
94 lines.join("\n")
95}
96
97fn build_plan(ledger: &crate::core::context_ledger::ContextLedger) -> String {
98 let mut lines = Vec::new();
99 for entry in &ledger.entries {
100 let short = crate::core::protocol::shorten_path(&entry.path);
101 let phi_str = entry.phi.map_or("?".to_string(), |p| format!("{p:.2}"));
102 let state_str = entry.state.as_ref().map_or("?", |s| match s {
103 crate::core::context_field::ContextState::Included => "incl",
104 crate::core::context_field::ContextState::Pinned => "pin",
105 crate::core::context_field::ContextState::Excluded => "excl",
106 crate::core::context_field::ContextState::Candidate => "cand",
107 crate::core::context_field::ContextState::Stale => "stale",
108 crate::core::context_field::ContextState::Shadowed => "shadow",
109 });
110 lines.push(format!(
111 "{short} mode={} tok={} phi={phi_str} state={state_str}",
112 entry.mode, entry.sent_tokens,
113 ));
114 }
115 if lines.is_empty() {
116 "No context items tracked yet.".to_string()
117 } else {
118 lines.join("\n")
119 }
120}
121
122fn build_pinned(ledger: &crate::core::context_ledger::ContextLedger) -> String {
123 let pinned: Vec<_> = ledger
124 .entries
125 .iter()
126 .filter(|e| e.state == Some(crate::core::context_field::ContextState::Pinned))
127 .collect();
128 if pinned.is_empty() {
129 return "No pinned items.".to_string();
130 }
131 let mut lines = Vec::new();
132 for entry in pinned {
133 let short = crate::core::protocol::shorten_path(&entry.path);
134 lines.push(format!(
135 "{short} mode={} tok={}",
136 entry.mode, entry.sent_tokens
137 ));
138 }
139 lines.join("\n")
140}
141
142fn build_bounce() -> String {
143 match crate::core::bounce_tracker::global().lock() {
144 Ok(bt) => bt.format_summary(),
145 _ => "Bounce tracker unavailable.".to_string(),
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn list_returns_five_resources() {
155 let resources = list_resources();
156 assert_eq!(resources.len(), 5);
157 }
158
159 #[test]
160 fn read_summary_returns_content() {
161 let ledger = crate::core::context_ledger::ContextLedger::new();
162 let result = read_resource(URI_SUMMARY, &ledger);
163 assert!(result.is_some());
164 }
165
166 #[test]
167 fn read_unknown_uri_returns_none() {
168 let ledger = crate::core::context_ledger::ContextLedger::new();
169 let result = read_resource("lean-ctx://unknown", &ledger);
170 assert!(result.is_none());
171 }
172
173 #[test]
174 fn read_pressure_returns_content() {
175 let ledger = crate::core::context_ledger::ContextLedger::new();
176 let result = read_resource(URI_PRESSURE, &ledger);
177 assert!(result.is_some());
178 }
179
180 #[test]
181 fn read_bounce_returns_content() {
182 let ledger = crate::core::context_ledger::ContextLedger::new();
183 let result = read_resource(URI_BOUNCE, &ledger);
184 assert!(result.is_some());
185 }
186}