1use 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 FALLBACK_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 explicit_budget = args
25 .and_then(|a| a.get("budget"))
26 .and_then(|v| {
27 v.as_u64()
28 .or_else(|| v.as_str().and_then(|s| s.parse::<u64>().ok()))
29 })
30 .map(|b| b as usize);
31 let default_budget = if ledger.window_size > 0 {
32 ledger.window_size
33 } else {
34 FALLBACK_BUDGET
35 };
36 let budget_tokens = explicit_budget.unwrap_or(default_budget);
37 let profile = get_str(args, "profile").unwrap_or_else(|| "balanced".to_string());
38
39 let field = ContextField::new();
40 let budget = TokenBudget {
41 total: budget_tokens,
42 used: 0,
43 };
44 let temperature = budget.temperature();
45
46 let intent_route = crate::core::intent_router::route_v1(&task);
47 let intent_mode = &intent_route.decision.effective_read_mode;
48
49 let mut plan_items: Vec<PlanItem> = Vec::new();
50 let mut total_estimated = 0usize;
51
52 for entry in &ledger.entries {
53 let path = &entry.path;
54 let seen_before = true;
55
56 let effective_state = policies.effective_state(
57 path,
58 entry.state.unwrap_or(ContextState::Included),
59 seen_before,
60 entry.original_tokens,
61 );
62 if effective_state == ContextState::Excluded {
63 plan_items.push(PlanItem {
64 path: path.clone(),
65 recommended_view: "excluded".to_string(),
66 estimated_tokens: 0,
67 phi: 0.0,
68 state: "excluded".to_string(),
69 reason: "policy".to_string(),
70 });
71 continue;
72 }
73
74 let phi = entry.phi.unwrap_or_else(|| {
75 let signals = FieldSignals {
76 relevance: if task != "general" && path.contains(&task) {
77 0.8
78 } else {
79 0.3
80 },
81 ..Default::default()
82 };
83 field.compute_phi(&signals)
84 });
85
86 let view_costs = entry
87 .view_costs
88 .clone()
89 .unwrap_or_else(|| ViewCosts::from_full_tokens(entry.original_tokens));
90
91 let recommended_view = policies
92 .recommended_view(path, seen_before, entry.original_tokens)
93 .unwrap_or_else(|| {
94 if intent_mode != "auto" && intent_mode != "reference" {
95 ViewKind::parse(intent_mode)
96 } else {
97 field.select_view(&view_costs, temperature)
98 }
99 });
100
101 let estimated_tokens = view_costs.get(&recommended_view);
102 total_estimated += estimated_tokens;
103
104 plan_items.push(PlanItem {
105 path: path.clone(),
106 recommended_view: recommended_view.as_str().to_string(),
107 estimated_tokens,
108 phi,
109 state: format!("{effective_state:?}"),
110 reason: format!("profile:{profile}"),
111 });
112 }
113
114 let loaded_paths: Vec<String> = ledger.entries.iter().map(|e| e.path.clone()).collect();
115 let (target_files, keywords) = crate::core::task_relevance::parse_task_hints(&task);
116 let classification = crate::core::intent_engine::classify(&task);
117 let structured = crate::core::intent_engine::StructuredIntent {
118 task_type: classification.task_type,
119 confidence: classification.confidence,
120 targets: target_files,
121 keywords,
122 scope: crate::core::intent_engine::IntentScope::MultiFile,
123 language_hint: None,
124 urgency: 0.5,
125 action_verb: None,
126 };
127 let deficit = crate::core::context_deficit::detect_deficit(ledger, &structured, &loaded_paths);
128
129 for suggestion in &deficit.suggested_files {
130 if !plan_items.iter().any(|p| p.path == suggestion.path) {
131 plan_items.push(PlanItem {
132 path: suggestion.path.clone(),
133 recommended_view: suggestion.recommended_mode.clone(),
134 estimated_tokens: suggestion.estimated_tokens,
135 phi: 0.5,
136 state: "suggested".to_string(),
137 reason: format!("{:?}", suggestion.reason),
138 });
139 total_estimated += suggestion.estimated_tokens;
140 }
141 }
142
143 plan_items.sort_by(|a, b| {
144 b.phi
145 .partial_cmp(&a.phi)
146 .unwrap_or(std::cmp::Ordering::Equal)
147 });
148
149 if total_estimated > budget_tokens {
150 degrade_views(&mut plan_items, budget_tokens, &mut total_estimated);
151 }
152
153 format_plan(&task, budget_tokens, total_estimated, &plan_items, &profile)
154}
155
156pub fn plan_to_candidates(ledger: &ContextLedger, policies: &PolicySet) -> Vec<CompileCandidate> {
158 let field = ContextField::new();
159 let mut candidates = Vec::new();
160
161 for entry in &ledger.entries {
162 let path = &entry.path;
163 let seen_before = true;
164 let effective_state = policies.effective_state(
165 path,
166 entry.state.unwrap_or(ContextState::Included),
167 seen_before,
168 entry.original_tokens,
169 );
170
171 let phi = entry.phi.unwrap_or_else(|| {
172 let signals = FieldSignals {
173 relevance: 0.3,
174 ..Default::default()
175 };
176 field.compute_phi(&signals)
177 });
178
179 let view_costs = entry
180 .view_costs
181 .clone()
182 .unwrap_or_else(|| ViewCosts::from_full_tokens(entry.original_tokens));
183
184 let item_id = entry
185 .id
186 .clone()
187 .unwrap_or_else(|| ContextItemId::from_file(path));
188
189 candidates.push(CompileCandidate {
190 id: item_id,
191 kind: entry.kind.unwrap_or(ContextKind::File),
192 path: path.clone(),
193 state: effective_state,
194 phi,
195 view_costs: view_costs.clone(),
196 selected_view: entry.active_view.unwrap_or(ViewKind::Full),
197 selected_tokens: entry.sent_tokens,
198 pinned: effective_state == ContextState::Pinned,
199 });
200 }
201
202 candidates
203}
204
205#[derive(Debug)]
206struct PlanItem {
207 path: String,
208 recommended_view: String,
209 estimated_tokens: usize,
210 phi: f64,
211 state: String,
212 reason: String,
213}
214
215fn format_plan(
216 task: &str,
217 budget: usize,
218 estimated: usize,
219 items: &[PlanItem],
220 profile: &str,
221) -> String {
222 let utilization = if budget > 0 {
223 estimated as f64 / budget as f64 * 100.0
224 } else {
225 0.0
226 };
227
228 let mut out = String::new();
229 out.push_str(&format!("[ctx_plan] task=\"{task}\" profile={profile}\n"));
230 out.push_str(&format!(
231 "Budget: {estimated}/{budget} tokens ({utilization:.1}% estimated)\n\n"
232 ));
233
234 let included: Vec<_> = items.iter().filter(|i| i.state != "excluded").collect();
235 let excluded: Vec<_> = items.iter().filter(|i| i.state == "excluded").collect();
236
237 if !included.is_empty() {
238 out.push_str("Planned items:\n");
239 for item in &included {
240 let default_reason = format!("profile:{profile}");
241 let extra = if item.reason.is_empty() || item.reason == default_reason {
242 String::new()
243 } else {
244 format!(" {}", item.reason)
245 };
246 out.push_str(&format!(
247 " {} {} {}t phi={:.2} [{}]{}\n",
248 item.path,
249 item.recommended_view,
250 item.estimated_tokens,
251 item.phi,
252 item.state,
253 extra
254 ));
255 }
256 }
257
258 if !excluded.is_empty() {
259 out.push_str(&format!("\nExcluded ({}):\n", excluded.len()));
260 for item in &excluded {
261 out.push_str(&format!(" {} — {}\n", item.path, item.reason));
262 }
263 }
264
265 if utilization > 90.0 {
266 out.push_str(
267 "\nWARNING: Estimated tokens exceed 90% of budget. Consider stricter views.\n",
268 );
269 }
270
271 out
272}
273
274fn degrade_views(items: &mut [PlanItem], budget: usize, total: &mut usize) {
275 let degrade_order: &[(&str, &str, f64)] = &[
276 ("full", "map", 0.3),
277 ("map", "signatures", 0.5),
278 ("signatures", "signatures", 0.7),
279 ];
280
281 for &(from, to, ratio) in degrade_order {
282 if *total <= budget {
283 break;
284 }
285 let mut candidates: Vec<usize> = items
286 .iter()
287 .enumerate()
288 .filter(|(_, it)| {
289 it.recommended_view == from && it.state != "excluded" && it.state != "Pinned"
290 })
291 .map(|(i, _)| i)
292 .collect();
293 candidates.sort_by(|&a, &b| {
294 items[a]
295 .phi
296 .partial_cmp(&items[b].phi)
297 .unwrap_or(std::cmp::Ordering::Equal)
298 });
299 for idx in candidates {
300 if *total <= budget {
301 break;
302 }
303 let old_tokens = items[idx].estimated_tokens;
304 let new_tokens = (old_tokens as f64 * ratio) as usize;
305 *total = total.saturating_sub(old_tokens) + new_tokens;
306 items[idx].estimated_tokens = new_tokens;
307 items[idx].recommended_view = to.to_string();
308 items[idx].reason = format!("degraded:{from}->{to}");
309 }
310 }
311}
312
313fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
314 args?
315 .get(key)?
316 .as_str()
317 .map(std::string::ToString::to_string)
318}