Skip to main content

lean_ctx/tools/
ctx_plan.rs

1//! ctx_plan -- Context planning tool.
2//!
3//! Given a task and budget, computes the optimal context plan using the
4//! Context Field potential function, intent router, and deficit analysis.
5
6use serde_json::Value;
7
8use crate::core::context_compiler::CompileCandidate;
9use crate::core::context_field::{
10    ContextField, ContextItemId, ContextKind, ContextState, FieldSignals, TokenBudget, ViewCosts,
11    ViewKind,
12};
13use crate::core::context_ledger::ContextLedger;
14use crate::core::context_policies::PolicySet;
15
16const DEFAULT_BUDGET: usize = 12_000;
17
18pub fn handle(
19    args: Option<&serde_json::Map<String, Value>>,
20    ledger: &ContextLedger,
21    policies: &PolicySet,
22) -> String {
23    let task = get_str(args, "task").unwrap_or_else(|| "general".to_string());
24    let budget_tokens: usize = args
25        .and_then(|a| a.get("budget"))
26        .and_then(serde_json::Value::as_u64)
27        .map_or(DEFAULT_BUDGET, |b| b as usize);
28    let profile = get_str(args, "profile").unwrap_or_else(|| "balanced".to_string());
29
30    let field = ContextField::new();
31    let budget = TokenBudget {
32        total: budget_tokens,
33        used: 0,
34    };
35    let temperature = budget.temperature();
36
37    let intent_route = crate::core::intent_router::route_v1(&task);
38    let intent_mode = &intent_route.decision.effective_read_mode;
39
40    let mut plan_items: Vec<PlanItem> = Vec::new();
41    let mut total_estimated = 0usize;
42
43    for entry in &ledger.entries {
44        let path = &entry.path;
45        let seen_before = true;
46
47        let effective_state = policies.effective_state(
48            path,
49            entry.state.unwrap_or(ContextState::Included),
50            seen_before,
51            entry.original_tokens,
52        );
53        if effective_state == ContextState::Excluded {
54            plan_items.push(PlanItem {
55                path: path.clone(),
56                recommended_view: "excluded".to_string(),
57                estimated_tokens: 0,
58                phi: 0.0,
59                state: "excluded".to_string(),
60                reason: "policy".to_string(),
61            });
62            continue;
63        }
64
65        let phi = entry.phi.unwrap_or_else(|| {
66            let signals = FieldSignals {
67                relevance: if task != "general" && path.contains(&task) {
68                    0.8
69                } else {
70                    0.3
71                },
72                ..Default::default()
73            };
74            field.compute_phi(&signals)
75        });
76
77        let view_costs = entry
78            .view_costs
79            .clone()
80            .unwrap_or_else(|| ViewCosts::from_full_tokens(entry.original_tokens));
81
82        let recommended_view = policies
83            .recommended_view(path, seen_before, entry.original_tokens)
84            .unwrap_or_else(|| {
85                if intent_mode != "auto" && intent_mode != "reference" {
86                    ViewKind::parse(intent_mode)
87                } else {
88                    field.select_view(&view_costs, temperature)
89                }
90            });
91
92        let estimated_tokens = view_costs.get(&recommended_view);
93        total_estimated += estimated_tokens;
94
95        plan_items.push(PlanItem {
96            path: path.clone(),
97            recommended_view: recommended_view.as_str().to_string(),
98            estimated_tokens,
99            phi,
100            state: format!("{effective_state:?}"),
101            reason: format!("profile:{profile}"),
102        });
103    }
104
105    let loaded_paths: Vec<String> = ledger.entries.iter().map(|e| e.path.clone()).collect();
106    let (target_files, keywords) = crate::core::task_relevance::parse_task_hints(&task);
107    let classification = crate::core::intent_engine::classify(&task);
108    let structured = crate::core::intent_engine::StructuredIntent {
109        task_type: classification.task_type,
110        confidence: classification.confidence,
111        targets: target_files,
112        keywords,
113        scope: crate::core::intent_engine::IntentScope::MultiFile,
114        language_hint: None,
115        urgency: 0.5,
116        action_verb: None,
117    };
118    let deficit = crate::core::context_deficit::detect_deficit(ledger, &structured, &loaded_paths);
119
120    for suggestion in &deficit.suggested_files {
121        if !plan_items.iter().any(|p| p.path == suggestion.path) {
122            plan_items.push(PlanItem {
123                path: suggestion.path.clone(),
124                recommended_view: suggestion.recommended_mode.clone(),
125                estimated_tokens: suggestion.estimated_tokens,
126                phi: 0.5,
127                state: "suggested".to_string(),
128                reason: format!("{:?}", suggestion.reason),
129            });
130            total_estimated += suggestion.estimated_tokens;
131        }
132    }
133
134    plan_items.sort_by(|a, b| {
135        b.phi
136            .partial_cmp(&a.phi)
137            .unwrap_or(std::cmp::Ordering::Equal)
138    });
139
140    format_plan(&task, budget_tokens, total_estimated, &plan_items, &profile)
141}
142
143/// Convert the plan into compile candidates for use with the compiler.
144pub fn plan_to_candidates(ledger: &ContextLedger, policies: &PolicySet) -> Vec<CompileCandidate> {
145    let field = ContextField::new();
146    let mut candidates = Vec::new();
147
148    for entry in &ledger.entries {
149        let path = &entry.path;
150        let seen_before = true;
151        let effective_state = policies.effective_state(
152            path,
153            entry.state.unwrap_or(ContextState::Included),
154            seen_before,
155            entry.original_tokens,
156        );
157
158        let phi = entry.phi.unwrap_or_else(|| {
159            let signals = FieldSignals {
160                relevance: 0.3,
161                ..Default::default()
162            };
163            field.compute_phi(&signals)
164        });
165
166        let view_costs = entry
167            .view_costs
168            .clone()
169            .unwrap_or_else(|| ViewCosts::from_full_tokens(entry.original_tokens));
170
171        let item_id = entry
172            .id
173            .clone()
174            .unwrap_or_else(|| ContextItemId::from_file(path));
175
176        candidates.push(CompileCandidate {
177            id: item_id,
178            kind: entry.kind.unwrap_or(ContextKind::File),
179            path: path.clone(),
180            state: effective_state,
181            phi,
182            view_costs: view_costs.clone(),
183            selected_view: entry.active_view.unwrap_or(ViewKind::Full),
184            selected_tokens: entry.sent_tokens,
185            pinned: effective_state == ContextState::Pinned,
186        });
187    }
188
189    candidates
190}
191
192#[derive(Debug)]
193struct PlanItem {
194    path: String,
195    recommended_view: String,
196    estimated_tokens: usize,
197    phi: f64,
198    state: String,
199    reason: String,
200}
201
202fn format_plan(
203    task: &str,
204    budget: usize,
205    estimated: usize,
206    items: &[PlanItem],
207    profile: &str,
208) -> String {
209    let utilization = if budget > 0 {
210        estimated as f64 / budget as f64 * 100.0
211    } else {
212        0.0
213    };
214
215    let mut out = String::new();
216    out.push_str(&format!("[ctx_plan] task=\"{task}\" profile={profile}\n"));
217    out.push_str(&format!(
218        "Budget: {estimated}/{budget} tokens ({utilization:.1}% estimated)\n\n"
219    ));
220
221    let included: Vec<_> = items.iter().filter(|i| i.state != "excluded").collect();
222    let excluded: Vec<_> = items.iter().filter(|i| i.state == "excluded").collect();
223
224    if !included.is_empty() {
225        out.push_str("Planned items:\n");
226        for item in &included {
227            out.push_str(&format!(
228                "  {} {} {}t phi={:.2} [{}]\n",
229                item.path, item.recommended_view, item.estimated_tokens, item.phi, item.state
230            ));
231        }
232    }
233
234    if !excluded.is_empty() {
235        out.push_str(&format!("\nExcluded ({}):\n", excluded.len()));
236        for item in &excluded {
237            out.push_str(&format!("  {} — {}\n", item.path, item.reason));
238        }
239    }
240
241    if utilization > 90.0 {
242        out.push_str(
243            "\nWARNING: Estimated tokens exceed 90% of budget. Consider stricter views.\n",
244        );
245    }
246
247    out
248}
249
250fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
251    args?
252        .get(key)?
253        .as_str()
254        .map(std::string::ToString::to_string)
255}