lean_ctx/tools/
ctx_control.rs1use 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(priority);
73 apply_overlay(overlays, &item_id, op, scope);
74 ledger.update_phi(&target, priority);
75 format!("[ctx_control] set priority for {target} to {priority:.2}")
76 }
77 "mark_outdated" => {
78 let op = OverlayOp::MarkOutdated;
79 apply_overlay(overlays, &item_id, op, scope);
80 ledger.set_state(&target, ContextState::Stale);
81 format!("[ctx_control] marked {target} as outdated")
82 }
83 "reset" => {
84 overlays.remove_for_item(&item_id);
85 ledger.set_state(&target, ContextState::Included);
86 format!("[ctx_control] reset all overlays for {target}")
87 }
88 "list" => {
89 let items = overlays.all();
90 if items.is_empty() {
91 "[ctx_control] no active overlays".to_string()
92 } else {
93 let mut out = format!("[ctx_control] {} active overlays:\n", items.len());
94 for ov in items {
95 let stale_tag = if ov.stale { " [STALE]" } else { "" };
96 out.push_str(&format!(
97 " {} -> {:?} ({:?}){}\n",
98 ov.target, ov.operation, ov.scope, stale_tag
99 ));
100 }
101 out
102 }
103 }
104 "history" => {
105 let history = overlays.for_item(&item_id);
106 if history.is_empty() {
107 format!("[ctx_control] no overlay history for {target}")
108 } else {
109 let mut out = format!(
110 "[ctx_control] {} overlays for {target}:\n",
111 history.len()
112 );
113 for ov in history {
114 out.push_str(&format!(
115 " {} {:?} at {} ({:?})\n",
116 ov.id, ov.operation, ov.created_at, ov.scope
117 ));
118 }
119 out
120 }
121 }
122 _ => format!("[ctx_control] unknown action: {action}. valid: exclude|include|pin|unpin|set_view|set_priority|mark_outdated|reset|list|history"),
123 }
124}
125
126fn resolve_target(target: &str, ledger: &ContextLedger) -> ContextItemId {
127 if target.starts_with("file:")
128 || target.starts_with("shell:")
129 || target.starts_with("knowledge:")
130 {
131 ContextItemId(target.to_string())
132 } else if let Some(stripped) = target.strip_prefix('@') {
133 ContextItemId::from_file(stripped)
134 } else if let Some(entry) = ledger.entries.iter().find(|e| e.path == target) {
135 entry
136 .id
137 .clone()
138 .unwrap_or_else(|| ContextItemId::from_file(target))
139 } else {
140 ContextItemId::from_file(target)
141 }
142}
143
144fn apply_overlay(
145 overlays: &mut OverlayStore,
146 item_id: &ContextItemId,
147 operation: OverlayOp,
148 scope: OverlayScope,
149) {
150 let overlay = ContextOverlay {
151 id: OverlayId::generate(item_id),
152 target: item_id.clone(),
153 operation,
154 scope,
155 before_hash: String::new(),
156 author: OverlayAuthor::Agent("mcp".to_string()),
157 created_at: chrono::Utc::now(),
158 stale: false,
159 };
160 overlays.add(overlay);
161}
162
163fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
164 args?
165 .get(key)?
166 .as_str()
167 .map(std::string::ToString::to_string)
168}