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
19pub(crate) const INFERRED_TASK_INTENT: &str = "inferred";
22pub(crate) const GIT_TASK_INTENT: &str = "git";
25
26pub fn is_auto_task_intent(intent: Option<&str>) -> bool {
29 matches!(intent, Some(INFERRED_TASK_INTENT | GIT_TASK_INTENT))
30}
31
32const GIT_TASK_MAX_FILES: usize = 3;
34
35pub(super) fn describe_uncommitted_files<'a>(
39 paths: impl IntoIterator<Item = &'a str>,
40) -> Option<String> {
41 let names: Vec<&str> = paths
42 .into_iter()
43 .map(str::trim)
44 .filter(|p| !p.is_empty())
45 .map(|p| p.rsplit('/').next().unwrap_or(p))
46 .collect();
47 if names.is_empty() {
48 return None;
49 }
50 let listed = names[..names.len().min(GIT_TASK_MAX_FILES)].join(", ");
51 let more = names.len().saturating_sub(GIT_TASK_MAX_FILES);
52 Some(if more > 0 {
53 format!("Uncommitted changes: {listed} (+{more} more)")
54 } else {
55 format!("Uncommitted changes: {listed}")
56 })
57}
58
59pub fn extract_session_facts(session: &SessionState) -> Vec<crate::core::knowledge::KnowledgeFact> {
61 session
62 .derived_facts()
63 .into_iter()
64 .map(|(fact, _)| fact)
65 .collect()
66}
67
68impl SessionState {
69 pub(crate) fn derived_facts(
75 &self,
76 ) -> Vec<(crate::core::knowledge::KnowledgeFact, Option<&Finding>)> {
77 derive_session_facts(self)
78 }
79}
80
81fn derive_session_facts(
82 session: &SessionState,
83) -> Vec<(crate::core::knowledge::KnowledgeFact, Option<&Finding>)> {
84 let mut facts = Vec::new();
85
86 facts.extend(session.decisions.iter().filter_map(|decision| {
87 let summary = decision.summary.trim();
88 if summary.is_empty() {
89 return None;
90 }
91 let value = decision
92 .rationale
93 .as_deref()
94 .filter(|rationale| !rationale.trim().is_empty())
95 .map_or_else(
96 || summary.to_owned(),
97 |rationale| format!("{summary} — {rationale}"),
98 );
99 Some((
100 auto_session_fact("auto:decision", value, session, decision.timestamp, 0.9),
101 None,
102 ))
103 }));
104
105 facts.extend(
106 session
107 .files_touched
108 .iter()
109 .filter(|file| file.modified)
110 .map(|file| {
111 let summary = file
112 .summary
113 .as_deref()
114 .filter(|summary| !summary.trim().is_empty())
115 .map_or_else(String::new, |summary| format!("; {summary}"));
116 let fact = auto_session_fact(
117 "auto:pattern",
118 format!(
119 "Modified {} (mode: {}, reads: {}){summary}",
120 file.path, file.last_mode, file.read_count
121 ),
122 session,
123 session.updated_at,
124 0.8,
125 );
126 (fact, None)
127 }),
128 );
129
130 facts.extend(session.findings.iter().filter_map(|finding| {
131 let summary = finding.summary.trim();
132 if summary.is_empty() || crate::core::auto_findings::is_activity_record(summary) {
133 return None;
134 }
135 let normalized = summary.to_ascii_lowercase();
136 let label = if ["resolved", "fixed", "solved", "unblocked", "passed"]
137 .iter()
138 .any(|keyword| normalized.contains(keyword))
139 {
140 "Resolution"
141 } else if [
142 "block", "fail", "error", "unable", "cannot", "missing", "timeout", "denied",
143 ]
144 .iter()
145 .any(|keyword| normalized.contains(keyword))
146 {
147 "Blocker"
148 } else {
149 "Finding"
150 };
151 let location = finding.file.as_deref().map_or_else(String::new, |file| {
152 finding
153 .line
154 .map_or_else(|| format!("{file}: "), |line| format!("{file}:{line}: "))
155 });
156 let fact = auto_session_fact(
157 "auto:blocker",
158 format!("{label}: {location}{summary}"),
159 session,
160 finding.timestamp,
161 0.75,
162 );
163 Some((fact, Some(finding)))
164 }));
165
166 facts
167}
168
169fn auto_session_fact(
170 category: &str,
171 value: String,
172 session: &SessionState,
173 timestamp: chrono::DateTime<Utc>,
174 confidence: f32,
175) -> crate::core::knowledge::KnowledgeFact {
176 let key = value.clone();
177 crate::core::knowledge::KnowledgeFact {
178 anchors: Vec::new(),
179 review: None,
180 capture: Default::default(),
181 origin: crate::core::knowledge::FactOrigin::Unverified,
182 category: category.to_owned(),
183 key,
184 value,
185 source_session: session.id.clone(),
186 confidence,
187 created_at: timestamp,
188 last_confirmed: timestamp,
189 retrieval_count: 0,
190 last_retrieved: None,
191 valid_from: None,
192 valid_until: None,
193 supersedes: None,
194 confirmation_count: 1,
195 feedback_up: 0,
196 feedback_down: 0,
197 last_feedback: None,
198 privacy: Default::default(),
199 sensitivity: Default::default(),
200 imported_from: None,
201 archetype: Default::default(),
202 fidelity: None,
203 revision_count: 0,
204 status: None,
205 validity: None,
206 }
207}
208
209impl Default for SessionState {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215impl SessionState {
216 pub fn new() -> Self {
218 let now = Utc::now();
219 Self {
220 canonical_checkpoint: None,
221 save_gate: Default::default(),
222 last_save_failed: false,
223 id: generate_session_id(),
224 app_session_id: crate::core::activity::session_id_from_env(),
225 version: 0,
226 started_at: now,
227 updated_at: now,
228 project_root: None,
229 shell_cwd: None,
230 task: None,
231 findings: Vec::new(),
232 decisions: Vec::new(),
233 handoff_context: Vec::new(),
234 files_touched: Vec::new(),
235 test_results: None,
236 progress: Vec::new(),
237 next_steps: Vec::new(),
238 evidence: Vec::new(),
239 intents: Vec::new(),
240 active_structured_intent: None,
241 stats: SessionStats::default(),
242 terse_mode: false,
243 compression_level: String::new(),
244 last_consolidate_ts: None,
245 last_aaak_hash: None,
246 extra_roots: Vec::new(),
247 live_zone: super::types::LiveZoneSessionState::default(),
248 playbook: super::playbook::Playbook::default(),
249 last_semantic_query: None,
250 taint: Default::default(),
251 last_flush: None,
252 }
253 .with_compression_from_config()
254 }
255
256 pub fn refresh_handoff_context(&mut self) {
257 let Some(project_root) = self.project_root.clone() else {
258 self.handoff_context.clear();
259 return;
260 };
261
262 let session_id = self.id.clone();
263 self.handoff_context = crate::core::knowledge::ProjectKnowledge::load(&project_root)
264 .map(|knowledge| {
265 let deliverable = knowledge.delivery_filter();
269 knowledge
270 .recent_decisions(10)
271 .into_iter()
272 .filter(|fact| fact.source_session != session_id && deliverable(fact))
273 .collect()
274 })
275 .unwrap_or_default();
276 }
277
278 fn with_compression_from_config(mut self) -> Self {
279 let cfg = crate::core::config::Config::load();
280 let level = crate::core::config::CompressionLevel::effective(&cfg);
281 self.compression_level = level.label().to_string();
282 self.terse_mode = level.is_active();
283 self
284 }
285
286 pub fn increment(&mut self) {
288 self.version += 1;
289 self.updated_at = Utc::now();
290 self.stats.unsaved_changes += 1;
291 }
292
293 pub fn should_save(&self) -> bool {
300 if self.stats.unsaved_changes == 0 {
301 return false;
302 }
303 if self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL {
304 return true;
305 }
306 match self.last_flush {
307 Some(t) => t.elapsed() >= SESSION_FLUSH_INTERVAL,
308 None => true,
309 }
310 }
311
312 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
314 let (description_clean, _) =
315 crate::core::secret_detection::scan_and_redact_from_config(description);
316 let progress_pct = if self.canonical_checkpoint.is_some() {
318 self.task.as_ref().and_then(|task| task.progress_pct)
319 } else {
320 None
321 };
322 self.task = Some(TaskInfo {
323 description: description_clean.clone(),
324 intent: intent.map(std::string::ToString::to_string),
325 progress_pct,
326 });
327 if is_auto_task_intent(intent) {
330 self.increment();
331 return;
332 }
333
334 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
335 let si = if touched.is_empty() {
336 crate::core::intent_engine::StructuredIntent::from_query(&description_clean)
337 } else {
338 crate::core::intent_engine::StructuredIntent::from_query_with_session(
339 &description_clean,
340 &touched,
341 )
342 };
343 if si.confidence >= 0.7 {
344 self.active_structured_intent = Some(si);
345 }
346
347 self.increment();
348 }
349
350 pub fn auto_infer_task(&mut self) {
355 if self
356 .task
357 .as_ref()
358 .is_some_and(|t| !is_auto_task_intent(t.intent.as_deref()))
359 {
360 return;
361 }
362 let inferred = self.infer_task();
363 let unchanged = match (&self.task, &inferred) {
364 (Some(t), Some((desc, intent))) => {
365 t.description == *desc && t.intent.as_deref() == Some(*intent)
366 }
367 (None, None) => true,
368 _ => false,
369 };
370 if unchanged {
371 return;
372 }
373 if let Some((desc, intent)) = inferred {
374 self.set_task(&desc, Some(intent));
375 } else {
376 self.task = None;
379 self.increment();
380 }
381 }
382
383 fn infer_task(&self) -> Option<(String, &'static str)> {
386 if let Some(task_from_plan) = Self::infer_task_from_plans() {
387 return Some((task_from_plan, "plan"));
388 }
389 if let Some(ref root) = self.project_root
390 && let Some(task_from_git) = Self::infer_task_from_git(root)
391 {
392 return Some((task_from_git, GIT_TASK_INTENT));
393 }
394 if self.files_touched.len() < 3 {
395 return None;
396 }
397 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
398 let intent = crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
399 if intent.confidence < 0.5 {
400 return None;
401 }
402 let dirs: std::collections::BTreeSet<&str> = touched
403 .iter()
404 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
405 .collect();
406 let primary_dir = dirs.iter().next().unwrap_or(&".");
407 Some((
408 format!("Working on {} ({})", primary_dir, intent.task_type.as_str()),
409 INFERRED_TASK_INTENT,
410 ))
411 }
412
413 fn infer_task_from_plans() -> Option<String> {
414 let plans_dir = std::path::Path::new(".cursor/plans");
415 if !plans_dir.exists() {
416 return None;
417 }
418
419 let mut newest: Option<(std::time::SystemTime, String)> = None;
420 if let Ok(entries) = std::fs::read_dir(plans_dir) {
421 for entry in entries.flatten() {
422 let path = entry.path();
423 if !path.to_string_lossy().ends_with(".plan.md") {
424 continue;
425 }
426 let mtime = entry.metadata().ok()?.modified().ok()?;
427 let content = std::fs::read_to_string(&path).ok()?;
428
429 let has_active =
431 content.contains("status: pending") || content.contains("status: in_progress");
432 if !has_active {
433 continue;
434 }
435
436 let name = content
438 .lines()
439 .find(|l| l.starts_with("name:"))
440 .map_or("Unknown Plan", |l| {
441 l.trim_start_matches("name:").trim().trim_matches('"')
442 });
443
444 let better = newest.as_ref().is_none_or(|(t, _)| mtime > *t);
445 if better {
446 newest = Some((mtime, name.to_string()));
447 }
448 }
449 }
450
451 newest.map(|(_, name)| name)
452 }
453
454 fn infer_task_from_git(project_root: &str) -> Option<String> {
455 let output = std::process::Command::new("git")
456 .args(["diff", "--name-only", "--no-color"])
457 .current_dir(project_root)
458 .output()
459 .ok()?;
460 if !output.status.success() {
461 return None;
462 }
463 describe_uncommitted_files(String::from_utf8_lossy(&output.stdout).lines())
464 }
465
466 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
468 let (summary_clean, _) =
469 crate::core::secret_detection::scan_and_redact_from_config(summary);
470 self.findings.push(Finding {
471 file: file.map(std::string::ToString::to_string),
472 line,
473 summary: summary_clean,
474 timestamp: Utc::now(),
475 });
476 while self.findings.len() > MAX_FINDINGS {
477 self.findings.remove(0);
478 }
479 self.increment();
480 }
481
482 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
484 let (summary_clean, _) =
485 crate::core::secret_detection::scan_and_redact_from_config(summary);
486 let rationale_clean =
487 rationale.map(|r| crate::core::secret_detection::scan_and_redact_from_config(r).0);
488 self.decisions.push(Decision {
489 summary: summary_clean,
490 rationale: rationale_clean,
491 timestamp: Utc::now(),
492 });
493 while self.decisions.len() > MAX_DECISIONS {
494 self.decisions.remove(0);
495 }
496 self.increment();
497 }
498
499 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
501 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
502 existing.read_count += 1;
503 existing.last_mode = mode.to_string();
504 existing.tokens = tokens;
505 if let Some(r) = file_ref {
506 existing.file_ref = Some(r.to_string());
507 }
508 } else {
509 let item_id = crate::core::context_field::ContextItemId::from_file(path);
510 self.files_touched.push(FileTouched {
511 path: path.to_string(),
512 file_ref: file_ref.map(std::string::ToString::to_string),
513 read_count: 1,
514 modified: false,
515 last_mode: mode.to_string(),
516 tokens,
517 stale: false,
518 context_item_id: Some(item_id.to_string()),
519 summary: None,
520 });
521 while self.files_touched.len() > MAX_FILES {
522 self.files_touched.remove(0);
523 }
524 }
525 self.stats.files_read += 1;
526 self.increment();
527 }
528
529 pub fn mark_modified(&mut self, path: &str) {
531 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
532 existing.modified = true;
533 }
534 self.increment();
535 }
536
537 pub fn set_file_summary(&mut self, path: &str, summary: &str) {
539 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
540 let truncated = if summary.len() > 80 {
541 format!("{}…", &summary[..79])
542 } else {
543 summary.to_string()
544 };
545 existing.summary = Some(truncated);
546 }
547 }
548
549 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
551 self.stats.total_tool_calls += 1;
552 self.stats.total_tokens_saved += tokens_saved;
553 self.stats.total_tokens_input += tokens_input;
554 self.stats.day_mut(Utc::now()).tool_calls += 1;
555 if self.stats.client.is_none() {
556 let client = crate::core::client_capabilities::current().client_id;
557 if client != "unknown" {
558 self.stats.client = Some(client);
559 }
560 }
561 }
562
563 pub fn record_intent(&mut self, mut intent: IntentRecord) {
565 if intent.occurrences == 0 {
566 intent.occurrences = 1;
567 }
568
569 if let Some(last) = self.intents.last_mut()
570 && last.fingerprint() == intent.fingerprint()
571 {
572 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
573 last.timestamp = intent.timestamp;
574 match intent.source {
575 IntentSource::Inferred => self.stats.intents_inferred += 1,
576 IntentSource::Explicit => self.stats.intents_explicit += 1,
577 }
578 self.increment();
579 return;
580 }
581
582 match intent.source {
583 IntentSource::Inferred => self.stats.intents_inferred += 1,
584 IntentSource::Explicit => self.stats.intents_explicit += 1,
585 }
586
587 self.intents.push(intent);
588 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
589 self.intents.remove(0);
590 }
591 self.increment();
592 }
593
594 pub fn record_tool_receipt(
596 &mut self,
597 tool: &str,
598 action: Option<&str>,
599 input_md5: &str,
600 output_md5: &str,
601 agent_id: Option<&str>,
602 client_name: Option<&str>,
603 ) {
604 self.record_tool_receipt_with_task(
605 tool,
606 action,
607 input_md5,
608 output_md5,
609 agent_id,
610 client_name,
611 None,
612 );
613 }
614
615 pub fn record_tool_receipt_with_task(
617 &mut self,
618 tool: &str,
619 action: Option<&str>,
620 input_md5: &str,
621 output_md5: &str,
622 agent_id: Option<&str>,
623 client_name: Option<&str>,
624 task_id: Option<&str>,
625 ) {
626 let now = Utc::now();
627 let mut push = |key: String| {
628 self.evidence.push(EvidenceRecord {
629 kind: EvidenceKind::ToolCall,
630 key,
631 value: None,
632 tool: Some(tool.to_string()),
633 input_md5: Some(input_md5.to_string()),
634 output_md5: Some(output_md5.to_string()),
635 agent_id: agent_id.map(std::string::ToString::to_string),
636 client_name: client_name.map(std::string::ToString::to_string),
637 task_id: task_id.map(std::string::ToString::to_string),
638 timestamp: now,
639 });
640 };
641
642 push(format!("tool:{tool}"));
643 if let Some(a) = action {
644 push(format!("tool:{tool}:{a}"));
645 }
646 while self.evidence.len() > MAX_EVIDENCE {
647 self.evidence.remove(0);
648 }
649 self.increment();
650 }
651
652 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
654 self.evidence.push(EvidenceRecord {
655 kind: EvidenceKind::Manual,
656 key: key.to_string(),
657 value: value.map(std::string::ToString::to_string),
658 tool: None,
659 input_md5: None,
660 output_md5: None,
661 agent_id: None,
662 client_name: None,
663 task_id: None,
664 timestamp: Utc::now(),
665 });
666 while self.evidence.len() > MAX_EVIDENCE {
667 self.evidence.remove(0);
668 }
669 self.increment();
670 }
671
672 pub fn has_evidence_key(&self, key: &str) -> bool {
674 self.evidence.iter().any(|e| e.key == key)
675 }
676
677 pub fn record_cache_hit(&mut self) {
679 self.stats.cache_hits += 1;
680 self.stats.day_mut(Utc::now()).cache_hits += 1;
681 }
682
683 pub fn record_command(&mut self) {
685 self.stats.commands_run += 1;
686 }
687
688 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
693 self.effective_cwd_checked(explicit_cwd).0
694 }
695
696 pub fn effective_cwd_checked(&self, explicit_cwd: Option<&str>) -> (String, Option<String>) {
708 let root = self.project_root.as_deref().unwrap_or(".");
709 if let Some(cwd) = explicit_cwd
710 && !cwd.is_empty()
711 && cwd != "."
712 {
713 return Self::jail_cwd(cwd, root);
714 }
715 if let Some(ref cwd) = self.shell_cwd {
716 return (cwd.clone(), None);
717 }
718 if let Some(ref r) = self.project_root {
719 return (r.clone(), None);
720 }
721 (
722 std::env::current_dir()
723 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string()),
724 None,
725 )
726 }
727
728 pub fn execution_cwd(&self, explicit_cwd: Option<&str>) -> Result<String, String> {
738 let jailed = |candidate: &str, root: &str| {
739 crate::core::pathjail::jail_path(
740 std::path::Path::new(candidate),
741 std::path::Path::new(root),
742 )
743 .map(|path| path.to_string_lossy().to_string())
744 .map_err(|reason| reason.to_string())
745 };
746 if let Some(cwd) = explicit_cwd
747 && !cwd.is_empty()
748 && cwd != "."
749 {
750 return jailed(cwd, self.project_root.as_deref().unwrap_or("."));
751 }
752 match (self.shell_cwd.as_deref(), self.project_root.as_deref()) {
753 (Some(cwd), Some(root)) => jailed(cwd, root),
754 (Some(cwd), None) => Ok(cwd.to_string()),
755 (None, Some(root)) => Ok(root.to_string()),
756 (None, None) => Ok(std::env::current_dir()
757 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())),
758 }
759 }
760
761 fn jail_cwd(candidate: &str, fallback_root: &str) -> (String, Option<String>) {
768 let p = std::path::Path::new(candidate);
769 match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
770 Ok(jailed) => (jailed.to_string_lossy().to_string(), None),
771 Err(reason) => (fallback_root.to_string(), Some(reason.to_string())),
772 }
773 }
774
775 pub fn note_explicit_cwd(&mut self, cwd: &str) {
783 let path = std::path::Path::new(cwd);
784 if path.is_absolute() && path.is_dir() {
785 self.shell_cwd = Some(
786 crate::core::pathutil::safe_canonicalize_or_self(path)
787 .to_string_lossy()
788 .to_string(),
789 );
790 }
791 }
792
793 pub fn update_shell_cwd(&mut self, command: &str) {
798 let base = self.effective_cwd(None);
799 if let Some(new_cwd) = extract_cd_target(command, &base) {
800 let path = std::path::Path::new(&new_cwd);
801 if path.exists() && path.is_dir() {
802 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
803 .to_string_lossy()
804 .to_string();
805 let root = self.project_root.as_deref().unwrap_or(".");
806 if crate::core::pathjail::jail_path(
807 std::path::Path::new(&canonical),
808 std::path::Path::new(root),
809 )
810 .is_ok()
811 {
812 self.shell_cwd = Some(canonical);
813 }
814 }
815 }
816 }
817}