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 version: 0,
225 started_at: now,
226 updated_at: now,
227 project_root: None,
228 shell_cwd: None,
229 task: None,
230 findings: Vec::new(),
231 decisions: Vec::new(),
232 handoff_context: Vec::new(),
233 files_touched: Vec::new(),
234 test_results: None,
235 progress: Vec::new(),
236 next_steps: Vec::new(),
237 evidence: Vec::new(),
238 intents: Vec::new(),
239 active_structured_intent: None,
240 stats: SessionStats::default(),
241 terse_mode: false,
242 compression_level: String::new(),
243 last_consolidate_ts: None,
244 last_aaak_hash: None,
245 extra_roots: Vec::new(),
246 live_zone: super::types::LiveZoneSessionState::default(),
247 playbook: super::playbook::Playbook::default(),
248 last_semantic_query: None,
249 last_flush: None,
250 }
251 .with_compression_from_config()
252 }
253
254 pub fn refresh_handoff_context(&mut self) {
255 let Some(project_root) = self.project_root.clone() else {
256 self.handoff_context.clear();
257 return;
258 };
259
260 let session_id = self.id.clone();
261 self.handoff_context = crate::core::knowledge::ProjectKnowledge::load(&project_root)
262 .map(|knowledge| {
263 let deliverable = knowledge.delivery_filter();
267 knowledge
268 .recent_decisions(10)
269 .into_iter()
270 .filter(|fact| fact.source_session != session_id && deliverable(fact))
271 .collect()
272 })
273 .unwrap_or_default();
274 }
275
276 fn with_compression_from_config(mut self) -> Self {
277 let cfg = crate::core::config::Config::load();
278 let level = crate::core::config::CompressionLevel::effective(&cfg);
279 self.compression_level = level.label().to_string();
280 self.terse_mode = level.is_active();
281 self
282 }
283
284 pub fn increment(&mut self) {
286 self.version += 1;
287 self.updated_at = Utc::now();
288 self.stats.unsaved_changes += 1;
289 }
290
291 pub fn should_save(&self) -> bool {
298 if self.stats.unsaved_changes == 0 {
299 return false;
300 }
301 if self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL {
302 return true;
303 }
304 match self.last_flush {
305 Some(t) => t.elapsed() >= SESSION_FLUSH_INTERVAL,
306 None => true,
307 }
308 }
309
310 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
312 let (description_clean, _) =
313 crate::core::secret_detection::scan_and_redact_from_config(description);
314 let progress_pct = if self.canonical_checkpoint.is_some() {
316 self.task.as_ref().and_then(|task| task.progress_pct)
317 } else {
318 None
319 };
320 self.task = Some(TaskInfo {
321 description: description_clean.clone(),
322 intent: intent.map(std::string::ToString::to_string),
323 progress_pct,
324 });
325 if is_auto_task_intent(intent) {
328 self.increment();
329 return;
330 }
331
332 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
333 let si = if touched.is_empty() {
334 crate::core::intent_engine::StructuredIntent::from_query(&description_clean)
335 } else {
336 crate::core::intent_engine::StructuredIntent::from_query_with_session(
337 &description_clean,
338 &touched,
339 )
340 };
341 if si.confidence >= 0.7 {
342 self.active_structured_intent = Some(si);
343 }
344
345 self.increment();
346 }
347
348 pub fn auto_infer_task(&mut self) {
353 if self
354 .task
355 .as_ref()
356 .is_some_and(|t| !is_auto_task_intent(t.intent.as_deref()))
357 {
358 return;
359 }
360 let inferred = self.infer_task();
361 let unchanged = match (&self.task, &inferred) {
362 (Some(t), Some((desc, intent))) => {
363 t.description == *desc && t.intent.as_deref() == Some(*intent)
364 }
365 (None, None) => true,
366 _ => false,
367 };
368 if unchanged {
369 return;
370 }
371 if let Some((desc, intent)) = inferred {
372 self.set_task(&desc, Some(intent));
373 } else {
374 self.task = None;
377 self.increment();
378 }
379 }
380
381 fn infer_task(&self) -> Option<(String, &'static str)> {
384 if let Some(task_from_plan) = Self::infer_task_from_plans() {
385 return Some((task_from_plan, "plan"));
386 }
387 if let Some(ref root) = self.project_root
388 && let Some(task_from_git) = Self::infer_task_from_git(root)
389 {
390 return Some((task_from_git, GIT_TASK_INTENT));
391 }
392 if self.files_touched.len() < 3 {
393 return None;
394 }
395 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
396 let intent = crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
397 if intent.confidence < 0.5 {
398 return None;
399 }
400 let dirs: std::collections::BTreeSet<&str> = touched
401 .iter()
402 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
403 .collect();
404 let primary_dir = dirs.iter().next().unwrap_or(&".");
405 Some((
406 format!("Working on {} ({})", primary_dir, intent.task_type.as_str()),
407 INFERRED_TASK_INTENT,
408 ))
409 }
410
411 fn infer_task_from_plans() -> Option<String> {
412 let plans_dir = std::path::Path::new(".cursor/plans");
413 if !plans_dir.exists() {
414 return None;
415 }
416
417 let mut newest: Option<(std::time::SystemTime, String)> = None;
418 if let Ok(entries) = std::fs::read_dir(plans_dir) {
419 for entry in entries.flatten() {
420 let path = entry.path();
421 if !path.to_string_lossy().ends_with(".plan.md") {
422 continue;
423 }
424 let mtime = entry.metadata().ok()?.modified().ok()?;
425 let content = std::fs::read_to_string(&path).ok()?;
426
427 let has_active =
429 content.contains("status: pending") || content.contains("status: in_progress");
430 if !has_active {
431 continue;
432 }
433
434 let name = content
436 .lines()
437 .find(|l| l.starts_with("name:"))
438 .map_or("Unknown Plan", |l| {
439 l.trim_start_matches("name:").trim().trim_matches('"')
440 });
441
442 let better = newest.as_ref().is_none_or(|(t, _)| mtime > *t);
443 if better {
444 newest = Some((mtime, name.to_string()));
445 }
446 }
447 }
448
449 newest.map(|(_, name)| name)
450 }
451
452 fn infer_task_from_git(project_root: &str) -> Option<String> {
453 let output = std::process::Command::new("git")
454 .args(["diff", "--name-only", "--no-color"])
455 .current_dir(project_root)
456 .output()
457 .ok()?;
458 if !output.status.success() {
459 return None;
460 }
461 describe_uncommitted_files(String::from_utf8_lossy(&output.stdout).lines())
462 }
463
464 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
466 let (summary_clean, _) =
467 crate::core::secret_detection::scan_and_redact_from_config(summary);
468 self.findings.push(Finding {
469 file: file.map(std::string::ToString::to_string),
470 line,
471 summary: summary_clean,
472 timestamp: Utc::now(),
473 });
474 while self.findings.len() > MAX_FINDINGS {
475 self.findings.remove(0);
476 }
477 self.increment();
478 }
479
480 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
482 let (summary_clean, _) =
483 crate::core::secret_detection::scan_and_redact_from_config(summary);
484 let rationale_clean =
485 rationale.map(|r| crate::core::secret_detection::scan_and_redact_from_config(r).0);
486 self.decisions.push(Decision {
487 summary: summary_clean,
488 rationale: rationale_clean,
489 timestamp: Utc::now(),
490 });
491 while self.decisions.len() > MAX_DECISIONS {
492 self.decisions.remove(0);
493 }
494 self.increment();
495 }
496
497 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
499 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
500 existing.read_count += 1;
501 existing.last_mode = mode.to_string();
502 existing.tokens = tokens;
503 if let Some(r) = file_ref {
504 existing.file_ref = Some(r.to_string());
505 }
506 } else {
507 let item_id = crate::core::context_field::ContextItemId::from_file(path);
508 self.files_touched.push(FileTouched {
509 path: path.to_string(),
510 file_ref: file_ref.map(std::string::ToString::to_string),
511 read_count: 1,
512 modified: false,
513 last_mode: mode.to_string(),
514 tokens,
515 stale: false,
516 context_item_id: Some(item_id.to_string()),
517 summary: None,
518 });
519 while self.files_touched.len() > MAX_FILES {
520 self.files_touched.remove(0);
521 }
522 }
523 self.stats.files_read += 1;
524 self.increment();
525 }
526
527 pub fn mark_modified(&mut self, path: &str) {
529 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
530 existing.modified = true;
531 }
532 self.increment();
533 }
534
535 pub fn set_file_summary(&mut self, path: &str, summary: &str) {
537 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
538 let truncated = if summary.len() > 80 {
539 format!("{}…", &summary[..79])
540 } else {
541 summary.to_string()
542 };
543 existing.summary = Some(truncated);
544 }
545 }
546
547 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
549 self.stats.total_tool_calls += 1;
550 self.stats.total_tokens_saved += tokens_saved;
551 self.stats.total_tokens_input += tokens_input;
552 self.stats.day_mut(Utc::now()).tool_calls += 1;
553 if self.stats.client.is_none() {
554 let client = crate::core::client_capabilities::current().client_id;
555 if client != "unknown" {
556 self.stats.client = Some(client);
557 }
558 }
559 }
560
561 pub fn record_intent(&mut self, mut intent: IntentRecord) {
563 if intent.occurrences == 0 {
564 intent.occurrences = 1;
565 }
566
567 if let Some(last) = self.intents.last_mut()
568 && last.fingerprint() == intent.fingerprint()
569 {
570 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
571 last.timestamp = intent.timestamp;
572 match intent.source {
573 IntentSource::Inferred => self.stats.intents_inferred += 1,
574 IntentSource::Explicit => self.stats.intents_explicit += 1,
575 }
576 self.increment();
577 return;
578 }
579
580 match intent.source {
581 IntentSource::Inferred => self.stats.intents_inferred += 1,
582 IntentSource::Explicit => self.stats.intents_explicit += 1,
583 }
584
585 self.intents.push(intent);
586 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
587 self.intents.remove(0);
588 }
589 self.increment();
590 }
591
592 pub fn record_tool_receipt(
594 &mut self,
595 tool: &str,
596 action: Option<&str>,
597 input_md5: &str,
598 output_md5: &str,
599 agent_id: Option<&str>,
600 client_name: Option<&str>,
601 ) {
602 self.record_tool_receipt_with_task(
603 tool,
604 action,
605 input_md5,
606 output_md5,
607 agent_id,
608 client_name,
609 None,
610 );
611 }
612
613 pub fn record_tool_receipt_with_task(
615 &mut self,
616 tool: &str,
617 action: Option<&str>,
618 input_md5: &str,
619 output_md5: &str,
620 agent_id: Option<&str>,
621 client_name: Option<&str>,
622 task_id: Option<&str>,
623 ) {
624 let now = Utc::now();
625 let mut push = |key: String| {
626 self.evidence.push(EvidenceRecord {
627 kind: EvidenceKind::ToolCall,
628 key,
629 value: None,
630 tool: Some(tool.to_string()),
631 input_md5: Some(input_md5.to_string()),
632 output_md5: Some(output_md5.to_string()),
633 agent_id: agent_id.map(std::string::ToString::to_string),
634 client_name: client_name.map(std::string::ToString::to_string),
635 task_id: task_id.map(std::string::ToString::to_string),
636 timestamp: now,
637 });
638 };
639
640 push(format!("tool:{tool}"));
641 if let Some(a) = action {
642 push(format!("tool:{tool}:{a}"));
643 }
644 while self.evidence.len() > MAX_EVIDENCE {
645 self.evidence.remove(0);
646 }
647 self.increment();
648 }
649
650 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
652 self.evidence.push(EvidenceRecord {
653 kind: EvidenceKind::Manual,
654 key: key.to_string(),
655 value: value.map(std::string::ToString::to_string),
656 tool: None,
657 input_md5: None,
658 output_md5: None,
659 agent_id: None,
660 client_name: None,
661 task_id: None,
662 timestamp: Utc::now(),
663 });
664 while self.evidence.len() > MAX_EVIDENCE {
665 self.evidence.remove(0);
666 }
667 self.increment();
668 }
669
670 pub fn has_evidence_key(&self, key: &str) -> bool {
672 self.evidence.iter().any(|e| e.key == key)
673 }
674
675 pub fn record_cache_hit(&mut self) {
677 self.stats.cache_hits += 1;
678 self.stats.day_mut(Utc::now()).cache_hits += 1;
679 }
680
681 pub fn record_command(&mut self) {
683 self.stats.commands_run += 1;
684 }
685
686 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
691 self.effective_cwd_checked(explicit_cwd).0
692 }
693
694 pub fn effective_cwd_checked(&self, explicit_cwd: Option<&str>) -> (String, Option<String>) {
706 let root = self.project_root.as_deref().unwrap_or(".");
707 if let Some(cwd) = explicit_cwd
708 && !cwd.is_empty()
709 && cwd != "."
710 {
711 return Self::jail_cwd(cwd, root);
712 }
713 if let Some(ref cwd) = self.shell_cwd {
714 return (cwd.clone(), None);
715 }
716 if let Some(ref r) = self.project_root {
717 return (r.clone(), None);
718 }
719 (
720 std::env::current_dir()
721 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string()),
722 None,
723 )
724 }
725
726 pub fn execution_cwd(&self, explicit_cwd: Option<&str>) -> Result<String, String> {
736 let jailed = |candidate: &str, root: &str| {
737 crate::core::pathjail::jail_path(
738 std::path::Path::new(candidate),
739 std::path::Path::new(root),
740 )
741 .map(|path| path.to_string_lossy().to_string())
742 .map_err(|reason| reason.to_string())
743 };
744 if let Some(cwd) = explicit_cwd
745 && !cwd.is_empty()
746 && cwd != "."
747 {
748 return jailed(cwd, self.project_root.as_deref().unwrap_or("."));
749 }
750 match (self.shell_cwd.as_deref(), self.project_root.as_deref()) {
751 (Some(cwd), Some(root)) => jailed(cwd, root),
752 (Some(cwd), None) => Ok(cwd.to_string()),
753 (None, Some(root)) => Ok(root.to_string()),
754 (None, None) => Ok(std::env::current_dir()
755 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())),
756 }
757 }
758
759 fn jail_cwd(candidate: &str, fallback_root: &str) -> (String, Option<String>) {
766 let p = std::path::Path::new(candidate);
767 match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
768 Ok(jailed) => (jailed.to_string_lossy().to_string(), None),
769 Err(reason) => (fallback_root.to_string(), Some(reason.to_string())),
770 }
771 }
772
773 pub fn note_explicit_cwd(&mut self, cwd: &str) {
781 let path = std::path::Path::new(cwd);
782 if path.is_absolute() && path.is_dir() {
783 self.shell_cwd = Some(
784 crate::core::pathutil::safe_canonicalize_or_self(path)
785 .to_string_lossy()
786 .to_string(),
787 );
788 }
789 }
790
791 pub fn update_shell_cwd(&mut self, command: &str) {
796 let base = self.effective_cwd(None);
797 if let Some(new_cwd) = extract_cd_target(command, &base) {
798 let path = std::path::Path::new(&new_cwd);
799 if path.exists() && path.is_dir() {
800 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
801 .to_string_lossy()
802 .to_string();
803 let root = self.project_root.as_deref().unwrap_or(".");
804 if crate::core::pathjail::jail_path(
805 std::path::Path::new(&canonical),
806 std::path::Path::new(root),
807 )
808 .is_ok()
809 {
810 self.shell_cwd = Some(canonical);
811 }
812 }
813 }
814 }
815}