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