1use serde_json::Value;
8
9use lean_ctx::core::context_field::{ContextItemId, ContextState, ViewKind};
10use lean_ctx::core::context_ledger::ContextLedger;
11use lean_ctx::core::context_overlay::{
12 ContextOverlay, OverlayAuthor, OverlayId, OverlayOp, OverlayScope, OverlayStore,
13};
14
15pub fn handle(
16 args: Option<&serde_json::Map<String, Value>>,
17 ledger: &mut ContextLedger,
18 overlays: &mut OverlayStore,
19) -> String {
20 let action = get_str(args, "action").unwrap_or_default();
21 let target = get_str(args, "target").unwrap_or_default();
22 let value = get_str(args, "value");
23 let scope_str = get_str(args, "scope").unwrap_or_else(|| "session".to_string());
24 let reason = get_str(args, "reason").unwrap_or_else(|| action.clone());
25
26 let scope = match scope_str.as_str() {
27 "call" => OverlayScope::Call,
28 "project" => OverlayScope::Project,
29 "global" => OverlayScope::Global,
30 _ => OverlayScope::Session,
31 };
32
33 let item_id = resolve_target(&target, ledger);
34
35 match action.as_str() {
36 "exclude" => {
37 let op = OverlayOp::Exclude { reason: reason.clone() };
38 apply_overlay(overlays, &item_id, op, scope);
39 ledger.set_state(&target, ContextState::Excluded);
40 format!("[ctx_control] excluded {target}: {reason}")
41 }
42 "include" => {
43 let op = OverlayOp::Include;
44 apply_overlay(overlays, &item_id, op, scope);
45 ledger.set_state(&target, ContextState::Included);
46 format!("[ctx_control] included {target}")
47 }
48 "pin" => {
49 let verbatim = value.as_deref() == Some("verbatim");
50 let op = OverlayOp::Pin { verbatim };
51 apply_overlay(overlays, &item_id, op, scope);
52 ledger.set_state(&target, ContextState::Pinned);
53 format!("[ctx_control] pinned {target}")
54 }
55 "unpin" => {
56 let op = OverlayOp::Unpin;
57 apply_overlay(overlays, &item_id, op, scope);
58 ledger.set_state(&target, ContextState::Included);
59 format!("[ctx_control] unpinned {target}")
60 }
61 "set_view" => {
62 let view_str = value.unwrap_or_else(|| "full".to_string());
63 let view = ViewKind::parse(&view_str);
64 let op = OverlayOp::SetView(view);
65 apply_overlay(overlays, &item_id, op, scope);
66 format!("[ctx_control] set view for {target} to {view_str}")
67 }
68 "set_priority" => {
69 let priority: f64 = value
70 .as_deref()
71 .and_then(|v| v.parse().ok())
72 .unwrap_or(0.5);
73 let op = OverlayOp::SetPriority {
74 set_priority: priority,
75 };
76 apply_overlay(overlays, &item_id, op, scope);
77 ledger.update_phi(&target, priority);
78 format!("[ctx_control] set priority for {target} to {priority:.2}")
79 }
80 "mark_outdated" => {
81 let op = OverlayOp::MarkOutdated;
82 apply_overlay(overlays, &item_id, op, scope);
83 ledger.set_state(&target, ContextState::Stale);
84 format!("[ctx_control] marked {target} as outdated")
85 }
86 "reset" => {
87 overlays.remove_for_item(&item_id);
88 ledger.set_state(&target, ContextState::Included);
89 format!("[ctx_control] reset all overlays for {target}")
90 }
91 "list" => {
92 let items = overlays.all();
93 if items.is_empty() {
94 "[ctx_control] no active overlays".to_string()
95 } else {
96 let mut out = format!("[ctx_control] {} active overlays:\n", items.len());
97 for ov in items {
98 let stale_tag = if ov.stale { " [stale]" } else { "" };
99 out.push_str(&format!(
100 " {} → {} ({}){}\n",
101 format_target(&ov.target),
102 format_operation(&ov.operation),
103 format_scope(&ov.scope),
104 stale_tag,
105 ));
106 }
107 out
108 }
109 }
110 "history" => {
111 let history = overlays.for_item(&item_id);
112 if history.is_empty() {
113 format!("[ctx_control] no overlay history for {target}")
114 } else {
115 let mut out = format!(
116 "[ctx_control] {} overlays for {target}:\n",
117 history.len()
118 );
119 for ov in history {
120 let age = format_age(&ov.created_at);
121 out.push_str(&format!(
122 " {} ({}, {})\n",
123 format_operation(&ov.operation),
124 format_scope(&ov.scope),
125 age,
126 ));
127 }
128 out
129 }
130 }
131 "help" => {
132 "[ctx_control] available actions:\n\
133 \x20 pin <target> — keep file in full mode (immune to eviction)\n\
134 \x20 unpin <target> — remove pin, allow compression\n\
135 \x20 exclude <target> — restrict to signatures only\n\
136 \x20 evict <targets> — drop files from the context ledger (comma-separated)\n\
137 \x20 include <target> — restore normal access\n\
138 \x20 set_view <target> — force a specific read mode (value: full|map|signatures|...)\n\
139 \x20 set_priority <target> — set Phi priority (value: 0.0-1.0)\n\
140 \x20 mark_outdated <target> — flag as stale, forces re-read\n\
141 \x20 reset <target> — clear all overlays for this file\n\
142 \x20 list — show all active overlays\n\
143 \x20 history <target> — show overlay history for a file"
144 .to_string()
145 }
146 _ => {
147 let suggestion = suggest_action(&action);
148 let base = format!("[ctx_control] unknown action: \"{action}\".");
149 if let Some(s) = suggestion {
150 format!("{base} Did you mean \"{s}\"?\nUse action=\"help\" for all available actions.")
151 } else {
152 format!("{base} Use action=\"help\" for all available actions.")
153 }
154 }
155 }
156}
157
158pub fn evict(
162 targets: &str,
163 ledger: &mut ContextLedger,
164 overlays: &mut OverlayStore,
165 project_root: &str,
166) -> String {
167 let targets: Vec<&str> = targets
168 .split(',')
169 .map(str::trim)
170 .filter(|t| !t.is_empty())
171 .collect();
172 if targets.is_empty() {
173 return "[ctx_control] evict needs targets (comma-separated paths).".to_string();
174 }
175 let root = if project_root.is_empty() {
176 "."
177 } else {
178 project_root
179 };
180 let outcomes = ledger.evict_paths_resolved(&targets, Some(root));
181 let removed = outcomes.iter().filter(|o| o.resolved.is_some()).count();
182 for resolved in outcomes.iter().filter_map(|o| o.resolved.as_ref()) {
183 overlays.add(ContextOverlay::new(
184 ContextItemId::from_file(resolved),
185 OverlayOp::Exclude {
186 reason: "evicted under context pressure".into(),
187 },
188 OverlayScope::Session,
189 String::new(),
190 OverlayAuthor::Policy("ctx_control_evict".into()),
191 ));
192 }
193 let pressure = ledger.pressure();
194 let mut lines = vec![format!(
195 "Evicted {removed}/{} target(s). Pressure now: {:.0}%. Files excluded from re-accumulation until session reset.",
196 targets.len(),
197 pressure.utilization * 100.0,
198 )];
199 for outcome in &outcomes {
200 match (&outcome.resolved, outcome.ambiguous.is_empty()) {
201 (Some(resolved), _) if resolved != &outcome.target => {
202 lines.push(format!(" {} → {resolved}", outcome.target));
203 }
204 (Some(_), _) => {}
205 (None, false) => lines.push(format!(
206 " {} is ambiguous ({}) — use a longer suffix",
207 outcome.target,
208 outcome.ambiguous.join(", ")
209 )),
210 (None, true) => lines.push(format!(" {} not in ledger", outcome.target)),
211 }
212 }
213 lines.join("\n")
214}
215
216fn resolve_target(target: &str, ledger: &ContextLedger) -> ContextItemId {
217 if target.starts_with("file:")
218 || target.starts_with("shell:")
219 || target.starts_with("knowledge:")
220 {
221 ContextItemId(target.to_string())
222 } else if let Some(stripped) = target.strip_prefix('@') {
223 ContextItemId::from_file(stripped)
224 } else if let Some(entry) = ledger.entries.iter().find(|e| e.path == target) {
225 entry
226 .id
227 .clone()
228 .unwrap_or_else(|| ContextItemId::from_file(target))
229 } else {
230 ContextItemId::from_file(target)
231 }
232}
233
234fn apply_overlay(
235 overlays: &mut OverlayStore,
236 item_id: &ContextItemId,
237 operation: OverlayOp,
238 scope: OverlayScope,
239) {
240 let overlay = ContextOverlay {
241 id: OverlayId::generate(item_id),
242 target: item_id.clone(),
243 operation,
244 scope,
245 before_hash: String::new(),
246 author: OverlayAuthor::Agent("mcp".to_string()),
247 created_at: chrono::Utc::now(),
248 stale: false,
249 };
250 overlays.add(overlay);
251}
252
253const VALID_ACTIONS: &[&str] = &[
254 "exclude",
255 "include",
256 "pin",
257 "unpin",
258 "set_view",
259 "set_priority",
260 "mark_outdated",
261 "reset",
262 "list",
263 "history",
264 "help",
265];
266
267fn suggest_action(input: &str) -> Option<&'static str> {
268 let input_lower = input.to_lowercase();
269 match input_lower.as_str() {
270 "evict" | "remove" => return Some("exclude"),
271 "compress" | "shrink" => return Some("set_view"),
272 "budget" => return Some("set_priority"),
273 _ => {}
274 }
275 VALID_ACTIONS
276 .iter()
277 .filter_map(|&action| {
278 let dist = levenshtein(&input_lower, action);
279 (dist <= 3).then_some((action, dist))
280 })
281 .min_by_key(|(_, dist)| *dist)
282 .map(|(a, _)| a)
283}
284
285fn levenshtein(a: &str, b: &str) -> usize {
286 let a: Vec<char> = a.chars().collect();
287 let b: Vec<char> = b.chars().collect();
288 let (rows, cols) = (a.len() + 1, b.len() + 1);
289 let mut matrix = vec![vec![0usize; cols]; rows];
290 for (i, row) in matrix.iter_mut().enumerate() {
291 row[0] = i;
292 }
293 #[allow(clippy::needless_range_loop)]
294 for j in 0..cols {
295 matrix[0][j] = j;
296 }
297 for i in 1..rows {
298 for j in 1..cols {
299 let cost = usize::from(a[i - 1] != b[j - 1]);
300 matrix[i][j] = (matrix[i - 1][j] + 1)
301 .min(matrix[i][j - 1] + 1)
302 .min(matrix[i - 1][j - 1] + cost);
303 }
304 }
305 matrix[a.len()][b.len()]
306}
307
308fn format_target(id: &ContextItemId) -> String {
309 let s = id.0.as_str();
310 if let Some(path) = s.strip_prefix("file:") {
311 lean_ctx::core::protocol::shorten_path(path)
312 } else {
313 s.to_string()
314 }
315}
316
317fn format_operation(op: &OverlayOp) -> String {
318 match op {
319 OverlayOp::Include => "included".to_string(),
320 OverlayOp::Exclude { reason } if reason == "exclude" => "excluded".to_string(),
321 OverlayOp::Exclude { reason } => format!("excluded ({reason})"),
322 OverlayOp::Pin { verbatim: true } => "pinned (verbatim)".to_string(),
323 OverlayOp::Pin { verbatim: false } => "pinned".to_string(),
324 OverlayOp::Unpin => "unpinned".to_string(),
325 OverlayOp::Rewrite { .. } => "rewrite".to_string(),
326 OverlayOp::SetView(v) => format!("view: {}", v.as_str()),
327 OverlayOp::SetPriority { set_priority } => format!("priority: {set_priority:.2}"),
328 OverlayOp::MarkOutdated => "outdated".to_string(),
329 OverlayOp::Expire { after_secs } => format!("expires in {after_secs}s"),
330 }
331}
332
333fn format_scope(scope: &OverlayScope) -> &'static str {
334 match scope {
335 OverlayScope::Call => "this call",
336 OverlayScope::Session => "this session",
337 OverlayScope::Project => "persistent",
338 OverlayScope::Global => "global",
339 OverlayScope::Agent(_) => "agent",
340 }
341}
342
343fn format_age(created_at: &chrono::DateTime<chrono::Utc>) -> String {
344 let elapsed = chrono::Utc::now().signed_duration_since(*created_at);
345 if elapsed.num_seconds() < 60 {
346 "just now".to_string()
347 } else if elapsed.num_minutes() < 60 {
348 format!("{}m ago", elapsed.num_minutes())
349 } else if elapsed.num_hours() < 24 {
350 format!("{}h ago", elapsed.num_hours())
351 } else {
352 format!("{}d ago", elapsed.num_days())
353 }
354}
355
356fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
357 args?
358 .get(key)?
359 .as_str()
360 .map(std::string::ToString::to_string)
361}