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