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 wakeup_manifest: Vec::new(),
48 playbook: super::playbook::Playbook::default(),
49 last_semantic_query: None,
50 }
51 .with_compression_from_config()
52 }
53
54 fn with_compression_from_config(mut self) -> Self {
55 let cfg = crate::core::config::Config::load();
56 let level = crate::core::config::CompressionLevel::effective(&cfg);
57 self.compression_level = level.label().to_string();
58 self.terse_mode = level.is_active();
59 self
60 }
61
62 pub fn increment(&mut self) {
64 self.version += 1;
65 self.updated_at = Utc::now();
66 self.stats.unsaved_changes += 1;
67 }
68
69 pub fn should_save(&self) -> bool {
71 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
72 }
73
74 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
76 self.task = Some(TaskInfo {
77 description: description.to_string(),
78 intent: intent.map(std::string::ToString::to_string),
79 progress_pct: None,
80 });
81
82 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
83 let si = if touched.is_empty() {
84 crate::core::intent_engine::StructuredIntent::from_query(description)
85 } else {
86 crate::core::intent_engine::StructuredIntent::from_query_with_session(
87 description,
88 &touched,
89 )
90 };
91 if si.confidence >= 0.7 {
92 self.active_structured_intent = Some(si);
93 }
94
95 self.increment();
96 }
97
98 pub fn auto_infer_task(&mut self) {
101 if self.task.is_some() {
103 return;
104 }
105
106 if let Some(task_from_plan) = Self::infer_task_from_plans() {
108 self.set_task(&task_from_plan, Some("plan"));
109 return;
110 }
111
112 if let Some(ref root) = self.project_root
114 && let Some(task_from_git) = Self::infer_task_from_git(root)
115 {
116 self.set_task(&task_from_git, Some("git"));
117 return;
118 }
119
120 if self.files_touched.len() >= 3 {
122 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
123 let intent = crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
124 if intent.confidence >= 0.5 {
125 let dirs: std::collections::HashSet<&str> = touched
126 .iter()
127 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
128 .collect();
129 let primary_dir = dirs.iter().next().unwrap_or(&".");
130 let desc = format!("Working on {} ({})", primary_dir, intent.task_type.as_str());
131 self.set_task(&desc, Some("inferred"));
132 }
133 }
134 }
135
136 fn infer_task_from_plans() -> Option<String> {
137 let plans_dir = std::path::Path::new(".cursor/plans");
138 if !plans_dir.exists() {
139 return None;
140 }
141
142 let mut newest: Option<(std::time::SystemTime, String)> = None;
143 if let Ok(entries) = std::fs::read_dir(plans_dir) {
144 for entry in entries.flatten() {
145 let path = entry.path();
146 if !path.to_string_lossy().ends_with(".plan.md") {
147 continue;
148 }
149 let mtime = entry.metadata().ok()?.modified().ok()?;
150 let content = std::fs::read_to_string(&path).ok()?;
151
152 let has_active =
154 content.contains("status: pending") || content.contains("status: in_progress");
155 if !has_active {
156 continue;
157 }
158
159 let name = content
161 .lines()
162 .find(|l| l.starts_with("name:"))
163 .map_or("Unknown Plan", |l| {
164 l.trim_start_matches("name:").trim().trim_matches('"')
165 });
166
167 let better = newest.as_ref().is_none_or(|(t, _)| mtime > *t);
168 if better {
169 newest = Some((mtime, name.to_string()));
170 }
171 }
172 }
173
174 newest.map(|(_, name)| name)
175 }
176
177 fn infer_task_from_git(project_root: &str) -> Option<String> {
178 let output = std::process::Command::new("git")
179 .args(["diff", "--stat", "--no-color"])
180 .current_dir(project_root)
181 .output()
182 .ok()?;
183
184 if !output.status.success() {
185 return None;
186 }
187
188 let stat = String::from_utf8_lossy(&output.stdout);
189 let lines: Vec<&str> = stat.lines().collect();
190 if lines.is_empty() {
191 return None;
192 }
193
194 let summary_line = lines.last()?;
196 if !summary_line.contains("changed") {
197 return None;
198 }
199
200 let file_lines: Vec<&str> = lines[..lines.len() - 1].to_vec();
202 let dirs: std::collections::HashSet<&str> = file_lines
203 .iter()
204 .filter_map(|l| {
205 let path = l.split('|').next()?.trim();
206 std::path::Path::new(path).parent()?.to_str()
207 })
208 .collect();
209
210 let primary = if dirs.len() == 1 {
211 dirs.into_iter().next().unwrap_or(".")
212 } else {
213 "multiple dirs"
214 };
215
216 Some(format!("Modified: {} in {}", summary_line.trim(), primary))
217 }
218
219 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
221 let (summary_clean, _) =
222 crate::core::secret_detection::scan_and_redact_from_config(summary);
223 self.findings.push(Finding {
224 file: file.map(std::string::ToString::to_string),
225 line,
226 summary: summary_clean,
227 timestamp: Utc::now(),
228 });
229 while self.findings.len() > MAX_FINDINGS {
230 self.findings.remove(0);
231 }
232 self.increment();
233 }
234
235 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
237 let (summary_clean, _) =
238 crate::core::secret_detection::scan_and_redact_from_config(summary);
239 let rationale_clean =
240 rationale.map(|r| crate::core::secret_detection::scan_and_redact_from_config(r).0);
241 self.decisions.push(Decision {
242 summary: summary_clean,
243 rationale: rationale_clean,
244 timestamp: Utc::now(),
245 });
246 while self.decisions.len() > MAX_DECISIONS {
247 self.decisions.remove(0);
248 }
249 self.increment();
250 }
251
252 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
254 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
255 existing.read_count += 1;
256 existing.last_mode = mode.to_string();
257 existing.tokens = tokens;
258 if let Some(r) = file_ref {
259 existing.file_ref = Some(r.to_string());
260 }
261 } else {
262 let item_id = crate::core::context_field::ContextItemId::from_file(path);
263 self.files_touched.push(FileTouched {
264 path: path.to_string(),
265 file_ref: file_ref.map(std::string::ToString::to_string),
266 read_count: 1,
267 modified: false,
268 last_mode: mode.to_string(),
269 tokens,
270 stale: false,
271 context_item_id: Some(item_id.to_string()),
272 summary: None,
273 });
274 while self.files_touched.len() > MAX_FILES {
275 self.files_touched.remove(0);
276 }
277 }
278 self.stats.files_read += 1;
279 self.increment();
280 }
281
282 pub fn mark_modified(&mut self, path: &str) {
284 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
285 existing.modified = true;
286 }
287 self.increment();
288 }
289
290 pub fn set_file_summary(&mut self, path: &str, summary: &str) {
292 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
293 let truncated = if summary.len() > 80 {
294 format!("{}…", &summary[..79])
295 } else {
296 summary.to_string()
297 };
298 existing.summary = Some(truncated);
299 }
300 }
301
302 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
304 self.stats.total_tool_calls += 1;
305 self.stats.total_tokens_saved += tokens_saved;
306 self.stats.total_tokens_input += tokens_input;
307 }
308
309 pub fn record_intent(&mut self, mut intent: IntentRecord) {
311 if intent.occurrences == 0 {
312 intent.occurrences = 1;
313 }
314
315 if let Some(last) = self.intents.last_mut()
316 && last.fingerprint() == intent.fingerprint()
317 {
318 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
319 last.timestamp = intent.timestamp;
320 match intent.source {
321 IntentSource::Inferred => self.stats.intents_inferred += 1,
322 IntentSource::Explicit => self.stats.intents_explicit += 1,
323 }
324 self.increment();
325 return;
326 }
327
328 match intent.source {
329 IntentSource::Inferred => self.stats.intents_inferred += 1,
330 IntentSource::Explicit => self.stats.intents_explicit += 1,
331 }
332
333 self.intents.push(intent);
334 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
335 self.intents.remove(0);
336 }
337 self.increment();
338 }
339
340 pub fn record_tool_receipt(
342 &mut self,
343 tool: &str,
344 action: Option<&str>,
345 input_md5: &str,
346 output_md5: &str,
347 agent_id: Option<&str>,
348 client_name: Option<&str>,
349 ) {
350 let now = Utc::now();
351 let mut push = |key: String| {
352 self.evidence.push(EvidenceRecord {
353 kind: EvidenceKind::ToolCall,
354 key,
355 value: None,
356 tool: Some(tool.to_string()),
357 input_md5: Some(input_md5.to_string()),
358 output_md5: Some(output_md5.to_string()),
359 agent_id: agent_id.map(std::string::ToString::to_string),
360 client_name: client_name.map(std::string::ToString::to_string),
361 timestamp: now,
362 });
363 };
364
365 push(format!("tool:{tool}"));
366 if let Some(a) = action {
367 push(format!("tool:{tool}:{a}"));
368 }
369 while self.evidence.len() > MAX_EVIDENCE {
370 self.evidence.remove(0);
371 }
372 self.increment();
373 }
374
375 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
377 self.evidence.push(EvidenceRecord {
378 kind: EvidenceKind::Manual,
379 key: key.to_string(),
380 value: value.map(std::string::ToString::to_string),
381 tool: None,
382 input_md5: None,
383 output_md5: None,
384 agent_id: None,
385 client_name: None,
386 timestamp: Utc::now(),
387 });
388 while self.evidence.len() > MAX_EVIDENCE {
389 self.evidence.remove(0);
390 }
391 self.increment();
392 }
393
394 pub fn has_evidence_key(&self, key: &str) -> bool {
396 self.evidence.iter().any(|e| e.key == key)
397 }
398
399 pub fn record_cache_hit(&mut self) {
401 self.stats.cache_hits += 1;
402 }
403
404 pub fn record_command(&mut self) {
406 self.stats.commands_run += 1;
407 }
408
409 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
414 self.effective_cwd_checked(explicit_cwd).0
415 }
416
417 pub fn effective_cwd_checked(&self, explicit_cwd: Option<&str>) -> (String, Option<String>) {
429 let root = self.project_root.as_deref().unwrap_or(".");
430 if let Some(cwd) = explicit_cwd
431 && !cwd.is_empty()
432 && cwd != "."
433 {
434 return Self::jail_cwd(cwd, root);
435 }
436 if let Some(ref cwd) = self.shell_cwd {
437 return (cwd.clone(), None);
438 }
439 if let Some(ref r) = self.project_root {
440 return (r.clone(), None);
441 }
442 (
443 std::env::current_dir()
444 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string()),
445 None,
446 )
447 }
448
449 fn jail_cwd(candidate: &str, fallback_root: &str) -> (String, Option<String>) {
456 let p = std::path::Path::new(candidate);
457 match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
458 Ok(jailed) => (jailed.to_string_lossy().to_string(), None),
459 Err(reason) => (fallback_root.to_string(), Some(reason)),
460 }
461 }
462
463 pub fn note_explicit_cwd(&mut self, cwd: &str) {
471 let path = std::path::Path::new(cwd);
472 if path.is_absolute() && path.is_dir() {
473 self.shell_cwd = Some(
474 crate::core::pathutil::safe_canonicalize_or_self(path)
475 .to_string_lossy()
476 .to_string(),
477 );
478 }
479 }
480
481 pub fn update_shell_cwd(&mut self, command: &str) {
486 let base = self.effective_cwd(None);
487 if let Some(new_cwd) = extract_cd_target(command, &base) {
488 let path = std::path::Path::new(&new_cwd);
489 if path.exists() && path.is_dir() {
490 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
491 .to_string_lossy()
492 .to_string();
493 let root = self.project_root.as_deref().unwrap_or(".");
494 if crate::core::pathjail::jail_path(
495 std::path::Path::new(&canonical),
496 std::path::Path::new(root),
497 )
498 .is_ok()
499 {
500 self.shell_cwd = Some(canonical);
501 }
502 }
503 }
504 }
505}