lean_ctx/server/
resources.rs1use rmcp::model::{Annotated, RawResource, 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 let raw = RawResource::new(uri, name)
55 .with_description(desc)
56 .with_mime_type("text/plain");
57 Annotated::new(raw, None)
58}
59
60fn build_summary(ledger: &crate::core::context_ledger::ContextLedger) -> String {
61 let pressure = ledger.pressure();
62 let adjusted = ledger.adjusted_total_saved();
63 format!(
64 "files:{} | sent:{} | saved:{} (adj:{}) | pressure:{:.0}% | action:{:?}",
65 ledger.entries.len(),
66 ledger.total_tokens_sent,
67 ledger.total_tokens_saved,
68 adjusted,
69 pressure.utilization * 100.0,
70 pressure.recommendation,
71 )
72}
73
74fn build_pressure(ledger: &crate::core::context_ledger::ContextLedger) -> String {
75 let p = ledger.pressure();
76 let mut lines = vec![
77 format!("utilization: {:.1}%", p.utilization * 100.0),
78 format!("remaining: {} tokens", p.remaining_tokens),
79 format!("entries: {}", p.entries_count),
80 format!("action: {:?}", p.recommendation),
81 ];
82
83 if p.utilization > 0.8 {
84 let evict = ledger.eviction_candidates_by_phi(3);
85 if !evict.is_empty() {
86 let names: Vec<_> = evict
87 .iter()
88 .take(5)
89 .map(|p| crate::core::protocol::shorten_path(p))
90 .collect();
91 lines.push(format!("eviction_candidates: {}", names.join(", ")));
92 }
93 }
94
95 lines.join("\n")
96}
97
98fn build_plan(ledger: &crate::core::context_ledger::ContextLedger) -> String {
99 let mut lines = Vec::new();
100 for entry in &ledger.entries {
101 let short = crate::core::protocol::shorten_path(&entry.path);
102 let phi_str = entry.phi.map_or("?".to_string(), |p| format!("{p:.2}"));
103 let state_str = entry.state.as_ref().map_or("?", |s| match s {
104 crate::core::context_field::ContextState::Included => "incl",
105 crate::core::context_field::ContextState::Pinned => "pin",
106 crate::core::context_field::ContextState::Excluded => "excl",
107 crate::core::context_field::ContextState::Candidate => "cand",
108 crate::core::context_field::ContextState::Stale => "stale",
109 crate::core::context_field::ContextState::Shadowed => "shadow",
110 });
111 lines.push(format!(
112 "{short} mode={} tok={} phi={phi_str} state={state_str}",
113 entry.mode, entry.sent_tokens,
114 ));
115 }
116 if lines.is_empty() {
117 "No context items tracked yet.".to_string()
118 } else {
119 lines.join("\n")
120 }
121}
122
123fn build_pinned(ledger: &crate::core::context_ledger::ContextLedger) -> String {
124 let pinned: Vec<_> = ledger
125 .entries
126 .iter()
127 .filter(|e| e.state == Some(crate::core::context_field::ContextState::Pinned))
128 .collect();
129 if pinned.is_empty() {
130 return "No pinned items.".to_string();
131 }
132 let mut lines = Vec::new();
133 for entry in pinned {
134 let short = crate::core::protocol::shorten_path(&entry.path);
135 lines.push(format!(
136 "{short} mode={} tok={}",
137 entry.mode, entry.sent_tokens
138 ));
139 }
140 lines.join("\n")
141}
142
143fn build_bounce() -> String {
144 if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
145 bt.format_summary()
146 } else {
147 "Bounce tracker unavailable.".to_string()
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn list_returns_five_resources() {
157 let resources = list_resources();
158 assert_eq!(resources.len(), 5);
159 }
160
161 #[test]
162 fn read_summary_returns_content() {
163 let ledger = crate::core::context_ledger::ContextLedger::new();
164 let result = read_resource(URI_SUMMARY, &ledger);
165 assert!(result.is_some());
166 }
167
168 #[test]
169 fn read_unknown_uri_returns_none() {
170 let ledger = crate::core::context_ledger::ContextLedger::new();
171 let result = read_resource("lean-ctx://unknown", &ledger);
172 assert!(result.is_none());
173 }
174
175 #[test]
176 fn read_pressure_returns_content() {
177 let ledger = crate::core::context_ledger::ContextLedger::new();
178 let result = read_resource(URI_PRESSURE, &ledger);
179 assert!(result.is_some());
180 }
181
182 #[test]
183 fn read_bounce_returns_content() {
184 let ledger = crate::core::context_ledger::ContextLedger::new();
185 let result = read_resource(URI_BOUNCE, &ledger);
186 assert!(result.is_some());
187 }
188}