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