1use chrono::Utc;
2
3use crate::core::intent_protocol::{IntentRecord, IntentSource};
4
5use super::paths::{extract_cd_target, generate_session_id};
6#[allow(clippy::wildcard_imports)]
7use super::types::*;
8
9const MAX_FINDINGS: usize = 20;
10const MAX_DECISIONS: usize = 10;
11const MAX_FILES: usize = 50;
12const MAX_EVIDENCE: usize = 500;
13pub(crate) const BATCH_SAVE_INTERVAL: u32 = 5;
14
15impl Default for SessionState {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl SessionState {
22 pub fn new() -> Self {
24 let now = Utc::now();
25 Self {
26 id: generate_session_id(),
27 version: 0,
28 started_at: now,
29 updated_at: now,
30 project_root: None,
31 shell_cwd: None,
32 task: None,
33 findings: Vec::new(),
34 decisions: Vec::new(),
35 files_touched: Vec::new(),
36 test_results: None,
37 progress: Vec::new(),
38 next_steps: Vec::new(),
39 evidence: Vec::new(),
40 intents: Vec::new(),
41 active_structured_intent: None,
42 stats: SessionStats::default(),
43 terse_mode: false,
44 compression_level: String::new(),
45 last_consolidate_ts: None,
46 extra_roots: Vec::new(),
47 }
48 .with_compression_from_config()
49 }
50
51 fn with_compression_from_config(mut self) -> Self {
52 let cfg = crate::core::config::Config::load();
53 let level = crate::core::config::CompressionLevel::effective(&cfg);
54 self.compression_level = level.label().to_string();
55 self.terse_mode = level.is_active();
56 self
57 }
58
59 pub fn increment(&mut self) {
61 self.version += 1;
62 self.updated_at = Utc::now();
63 self.stats.unsaved_changes += 1;
64 }
65
66 pub fn should_save(&self) -> bool {
68 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
69 }
70
71 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
73 self.task = Some(TaskInfo {
74 description: description.to_string(),
75 intent: intent.map(std::string::ToString::to_string),
76 progress_pct: None,
77 });
78
79 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
80 let si = if touched.is_empty() {
81 crate::core::intent_engine::StructuredIntent::from_query(description)
82 } else {
83 crate::core::intent_engine::StructuredIntent::from_query_with_session(
84 description,
85 &touched,
86 )
87 };
88 if si.confidence >= 0.7 {
89 self.active_structured_intent = Some(si);
90 }
91
92 self.increment();
93 }
94
95 pub fn auto_infer_task(&mut self) {
98 if self.task.is_some() {
100 return;
101 }
102
103 if let Some(task_from_plan) = Self::infer_task_from_plans() {
105 self.set_task(&task_from_plan, Some("plan"));
106 return;
107 }
108
109 if let Some(ref root) = self.project_root {
111 if let Some(task_from_git) = Self::infer_task_from_git(root) {
112 self.set_task(&task_from_git, Some("git"));
113 return;
114 }
115 }
116
117 if self.files_touched.len() >= 3 {
119 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
120 let intent = crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
121 if intent.confidence >= 0.5 {
122 let dirs: std::collections::HashSet<&str> = touched
123 .iter()
124 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
125 .collect();
126 let primary_dir = dirs.iter().next().unwrap_or(&".");
127 let desc = format!("Working on {} ({})", primary_dir, intent.task_type.as_str());
128 self.set_task(&desc, Some("inferred"));
129 }
130 }
131 }
132
133 fn infer_task_from_plans() -> Option<String> {
134 let plans_dir = std::path::Path::new(".cursor/plans");
135 if !plans_dir.exists() {
136 return None;
137 }
138
139 let mut newest: Option<(std::time::SystemTime, String)> = None;
140 if let Ok(entries) = std::fs::read_dir(plans_dir) {
141 for entry in entries.flatten() {
142 let path = entry.path();
143 if !path.to_string_lossy().ends_with(".plan.md") {
144 continue;
145 }
146 let mtime = entry.metadata().ok()?.modified().ok()?;
147 let content = std::fs::read_to_string(&path).ok()?;
148
149 let has_active =
151 content.contains("status: pending") || content.contains("status: in_progress");
152 if !has_active {
153 continue;
154 }
155
156 let name = content
158 .lines()
159 .find(|l| l.starts_with("name:"))
160 .map_or("Unknown Plan", |l| {
161 l.trim_start_matches("name:").trim().trim_matches('"')
162 });
163
164 let better = newest.as_ref().is_none_or(|(t, _)| mtime > *t);
165 if better {
166 newest = Some((mtime, name.to_string()));
167 }
168 }
169 }
170
171 newest.map(|(_, name)| name)
172 }
173
174 fn infer_task_from_git(project_root: &str) -> Option<String> {
175 let output = std::process::Command::new("git")
176 .args(["diff", "--stat", "--no-color"])
177 .current_dir(project_root)
178 .output()
179 .ok()?;
180
181 if !output.status.success() {
182 return None;
183 }
184
185 let stat = String::from_utf8_lossy(&output.stdout);
186 let lines: Vec<&str> = stat.lines().collect();
187 if lines.is_empty() {
188 return None;
189 }
190
191 let summary_line = lines.last()?;
193 if !summary_line.contains("changed") {
194 return None;
195 }
196
197 let file_lines: Vec<&str> = lines[..lines.len() - 1].to_vec();
199 let dirs: std::collections::HashSet<&str> = file_lines
200 .iter()
201 .filter_map(|l| {
202 let path = l.split('|').next()?.trim();
203 std::path::Path::new(path).parent()?.to_str()
204 })
205 .collect();
206
207 let primary = if dirs.len() == 1 {
208 dirs.into_iter().next().unwrap_or(".")
209 } else {
210 "multiple dirs"
211 };
212
213 Some(format!("Modified: {} in {}", summary_line.trim(), primary))
214 }
215
216 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
218 let (summary_clean, _) =
219 crate::core::secret_detection::scan_and_redact_from_config(summary);
220 self.findings.push(Finding {
221 file: file.map(std::string::ToString::to_string),
222 line,
223 summary: summary_clean,
224 timestamp: Utc::now(),
225 });
226 while self.findings.len() > MAX_FINDINGS {
227 self.findings.remove(0);
228 }
229 self.increment();
230 }
231
232 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
234 let (summary_clean, _) =
235 crate::core::secret_detection::scan_and_redact_from_config(summary);
236 let rationale_clean =
237 rationale.map(|r| crate::core::secret_detection::scan_and_redact_from_config(r).0);
238 self.decisions.push(Decision {
239 summary: summary_clean,
240 rationale: rationale_clean,
241 timestamp: Utc::now(),
242 });
243 while self.decisions.len() > MAX_DECISIONS {
244 self.decisions.remove(0);
245 }
246 self.increment();
247 }
248
249 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
251 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
252 existing.read_count += 1;
253 existing.last_mode = mode.to_string();
254 existing.tokens = tokens;
255 if let Some(r) = file_ref {
256 existing.file_ref = Some(r.to_string());
257 }
258 } else {
259 let item_id = crate::core::context_field::ContextItemId::from_file(path);
260 self.files_touched.push(FileTouched {
261 path: path.to_string(),
262 file_ref: file_ref.map(std::string::ToString::to_string),
263 read_count: 1,
264 modified: false,
265 last_mode: mode.to_string(),
266 tokens,
267 stale: false,
268 context_item_id: Some(item_id.to_string()),
269 summary: None,
270 });
271 while self.files_touched.len() > MAX_FILES {
272 self.files_touched.remove(0);
273 }
274 }
275 self.stats.files_read += 1;
276 self.increment();
277 }
278
279 pub fn mark_modified(&mut self, path: &str) {
281 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
282 existing.modified = true;
283 }
284 self.increment();
285 }
286
287 pub fn set_file_summary(&mut self, path: &str, summary: &str) {
289 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
290 let truncated = if summary.len() > 80 {
291 format!("{}…", &summary[..79])
292 } else {
293 summary.to_string()
294 };
295 existing.summary = Some(truncated);
296 }
297 }
298
299 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
301 self.stats.total_tool_calls += 1;
302 self.stats.total_tokens_saved += tokens_saved;
303 self.stats.total_tokens_input += tokens_input;
304 }
305
306 pub fn record_intent(&mut self, mut intent: IntentRecord) {
308 if intent.occurrences == 0 {
309 intent.occurrences = 1;
310 }
311
312 if let Some(last) = self.intents.last_mut() {
313 if last.fingerprint() == intent.fingerprint() {
314 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
315 last.timestamp = intent.timestamp;
316 match intent.source {
317 IntentSource::Inferred => self.stats.intents_inferred += 1,
318 IntentSource::Explicit => self.stats.intents_explicit += 1,
319 }
320 self.increment();
321 return;
322 }
323 }
324
325 match intent.source {
326 IntentSource::Inferred => self.stats.intents_inferred += 1,
327 IntentSource::Explicit => self.stats.intents_explicit += 1,
328 }
329
330 self.intents.push(intent);
331 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
332 self.intents.remove(0);
333 }
334 self.increment();
335 }
336
337 pub fn record_tool_receipt(
339 &mut self,
340 tool: &str,
341 action: Option<&str>,
342 input_md5: &str,
343 output_md5: &str,
344 agent_id: Option<&str>,
345 client_name: Option<&str>,
346 ) {
347 let now = Utc::now();
348 let mut push = |key: String| {
349 self.evidence.push(EvidenceRecord {
350 kind: EvidenceKind::ToolCall,
351 key,
352 value: None,
353 tool: Some(tool.to_string()),
354 input_md5: Some(input_md5.to_string()),
355 output_md5: Some(output_md5.to_string()),
356 agent_id: agent_id.map(std::string::ToString::to_string),
357 client_name: client_name.map(std::string::ToString::to_string),
358 timestamp: now,
359 });
360 };
361
362 push(format!("tool:{tool}"));
363 if let Some(a) = action {
364 push(format!("tool:{tool}:{a}"));
365 }
366 while self.evidence.len() > MAX_EVIDENCE {
367 self.evidence.remove(0);
368 }
369 self.increment();
370 }
371
372 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
374 self.evidence.push(EvidenceRecord {
375 kind: EvidenceKind::Manual,
376 key: key.to_string(),
377 value: value.map(std::string::ToString::to_string),
378 tool: None,
379 input_md5: None,
380 output_md5: None,
381 agent_id: None,
382 client_name: None,
383 timestamp: Utc::now(),
384 });
385 while self.evidence.len() > MAX_EVIDENCE {
386 self.evidence.remove(0);
387 }
388 self.increment();
389 }
390
391 pub fn has_evidence_key(&self, key: &str) -> bool {
393 self.evidence.iter().any(|e| e.key == key)
394 }
395
396 pub fn record_cache_hit(&mut self) {
398 self.stats.cache_hits += 1;
399 }
400
401 pub fn record_command(&mut self) {
403 self.stats.commands_run += 1;
404 }
405
406 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
411 let root = self.project_root.as_deref().unwrap_or(".");
412 if let Some(cwd) = explicit_cwd {
413 if !cwd.is_empty() && cwd != "." {
414 return Self::jail_cwd(cwd, root);
415 }
416 }
417 if let Some(ref cwd) = self.shell_cwd {
418 return cwd.clone();
419 }
420 if let Some(ref r) = self.project_root {
421 return r.clone();
422 }
423 std::env::current_dir()
424 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
425 }
426
427 fn jail_cwd(candidate: &str, fallback_root: &str) -> String {
430 let p = std::path::Path::new(candidate);
431 match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
432 Ok(jailed) => jailed.to_string_lossy().to_string(),
433 Err(_) => fallback_root.to_string(),
434 }
435 }
436
437 pub fn update_shell_cwd(&mut self, command: &str) {
442 let base = self.effective_cwd(None);
443 if let Some(new_cwd) = extract_cd_target(command, &base) {
444 let path = std::path::Path::new(&new_cwd);
445 if path.exists() && path.is_dir() {
446 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
447 .to_string_lossy()
448 .to_string();
449 let root = self.project_root.as_deref().unwrap_or(".");
450 if crate::core::pathjail::jail_path(
451 std::path::Path::new(&canonical),
452 std::path::Path::new(root),
453 )
454 .is_ok()
455 {
456 self.shell_cwd = Some(canonical);
457 }
458 }
459 }
460 }
461}