Skip to main content

lean_ctx_server/tools/
ctx_control.rs

1// SPDX-License-Identifier: Apache-2.0
2//! ctx_control -- Universal context manipulation tool.
3//!
4//! Single entry point for include/exclude/pin/rewrite/set_view operations.
5//! Delegates to the Overlay and Ledger systems.
6
7use 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
158/// Evicts ledger entries under context pressure (#715): resolves each
159/// comma-separated target against the ledger's canonical paths, removes it and
160/// adds a session-scoped exclude overlay so the file does not re-accumulate.
161pub 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}