1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::core::intent_protocol::{IntentRecord, IntentSource};
6
7const MAX_FINDINGS: usize = 20;
8const MAX_DECISIONS: usize = 10;
9const MAX_FILES: usize = 50;
10const MAX_EVIDENCE: usize = 500;
11const BATCH_SAVE_INTERVAL: u32 = 5;
12
13#[derive(Serialize, Deserialize, Clone, Debug)]
14pub struct SessionState {
15 pub id: String,
16 pub version: u32,
17 pub started_at: DateTime<Utc>,
18 pub updated_at: DateTime<Utc>,
19 pub project_root: Option<String>,
20 #[serde(default)]
21 pub shell_cwd: Option<String>,
22 pub task: Option<TaskInfo>,
23 pub findings: Vec<Finding>,
24 pub decisions: Vec<Decision>,
25 pub files_touched: Vec<FileTouched>,
26 pub test_results: Option<TestSnapshot>,
27 pub progress: Vec<ProgressEntry>,
28 pub next_steps: Vec<String>,
29 #[serde(default)]
30 pub evidence: Vec<EvidenceRecord>,
31 #[serde(default)]
32 pub intents: Vec<IntentRecord>,
33 #[serde(default)]
34 pub active_structured_intent: Option<crate::core::intent_engine::StructuredIntent>,
35 pub stats: SessionStats,
36}
37
38#[derive(Serialize, Deserialize, Clone, Debug)]
39pub struct TaskInfo {
40 pub description: String,
41 pub intent: Option<String>,
42 pub progress_pct: Option<u8>,
43}
44
45#[derive(Serialize, Deserialize, Clone, Debug)]
46pub struct Finding {
47 pub file: Option<String>,
48 pub line: Option<u32>,
49 pub summary: String,
50 pub timestamp: DateTime<Utc>,
51}
52
53#[derive(Serialize, Deserialize, Clone, Debug)]
54pub struct Decision {
55 pub summary: String,
56 pub rationale: Option<String>,
57 pub timestamp: DateTime<Utc>,
58}
59
60#[derive(Serialize, Deserialize, Clone, Debug)]
61pub struct FileTouched {
62 pub path: String,
63 pub file_ref: Option<String>,
64 pub read_count: u32,
65 pub modified: bool,
66 pub last_mode: String,
67 pub tokens: usize,
68}
69
70#[derive(Serialize, Deserialize, Clone, Debug)]
71pub struct TestSnapshot {
72 pub command: String,
73 pub passed: u32,
74 pub failed: u32,
75 pub total: u32,
76 pub timestamp: DateTime<Utc>,
77}
78
79#[derive(Serialize, Deserialize, Clone, Debug)]
80pub struct ProgressEntry {
81 pub action: String,
82 pub detail: Option<String>,
83 pub timestamp: DateTime<Utc>,
84}
85
86#[derive(Serialize, Deserialize, Clone, Debug)]
87#[serde(rename_all = "snake_case")]
88pub enum EvidenceKind {
89 ToolCall,
90 Manual,
91}
92
93#[derive(Serialize, Deserialize, Clone, Debug)]
94pub struct EvidenceRecord {
95 pub kind: EvidenceKind,
96 pub key: String,
97 pub value: Option<String>,
98 pub tool: Option<String>,
99 pub input_md5: Option<String>,
100 pub output_md5: Option<String>,
101 pub agent_id: Option<String>,
102 pub client_name: Option<String>,
103 pub timestamp: DateTime<Utc>,
104}
105
106#[derive(Serialize, Deserialize, Clone, Debug, Default)]
107#[serde(default)]
108pub struct SessionStats {
109 pub total_tool_calls: u32,
110 pub total_tokens_saved: u64,
111 pub total_tokens_input: u64,
112 pub cache_hits: u32,
113 pub files_read: u32,
114 pub commands_run: u32,
115 pub intents_inferred: u32,
116 pub intents_explicit: u32,
117 pub unsaved_changes: u32,
118}
119
120#[derive(Serialize, Deserialize, Clone, Debug)]
121struct LatestPointer {
122 id: String,
123}
124
125pub struct PreparedSave {
129 dir: PathBuf,
130 id: String,
131 json: String,
132 pointer_json: String,
133}
134
135impl PreparedSave {
136 pub fn write_to_disk(self) -> Result<(), String> {
137 if !self.dir.exists() {
138 std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
139 }
140 let path = self.dir.join(format!("{}.json", self.id));
141 let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
142 std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
143 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
144
145 let latest_path = self.dir.join("latest.json");
146 let latest_tmp = self.dir.join(".latest.json.tmp");
147 std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
148 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
149 Ok(())
150 }
151}
152
153impl Default for SessionState {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159impl SessionState {
160 pub fn new() -> Self {
161 let now = Utc::now();
162 Self {
163 id: generate_session_id(),
164 version: 0,
165 started_at: now,
166 updated_at: now,
167 project_root: None,
168 shell_cwd: None,
169 task: None,
170 findings: Vec::new(),
171 decisions: Vec::new(),
172 files_touched: Vec::new(),
173 test_results: None,
174 progress: Vec::new(),
175 next_steps: Vec::new(),
176 evidence: Vec::new(),
177 intents: Vec::new(),
178 active_structured_intent: None,
179 stats: SessionStats::default(),
180 }
181 }
182
183 pub fn increment(&mut self) {
184 self.version += 1;
185 self.updated_at = Utc::now();
186 self.stats.unsaved_changes += 1;
187 }
188
189 pub fn should_save(&self) -> bool {
190 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
191 }
192
193 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
194 self.task = Some(TaskInfo {
195 description: description.to_string(),
196 intent: intent.map(|s| s.to_string()),
197 progress_pct: None,
198 });
199
200 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
201 let si = if touched.is_empty() {
202 crate::core::intent_engine::StructuredIntent::from_query(description)
203 } else {
204 crate::core::intent_engine::StructuredIntent::from_query_with_session(
205 description,
206 &touched,
207 )
208 };
209 if si.confidence >= 0.7 {
210 self.active_structured_intent = Some(si);
211 }
212
213 self.increment();
214 }
215
216 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
217 self.findings.push(Finding {
218 file: file.map(|s| s.to_string()),
219 line,
220 summary: summary.to_string(),
221 timestamp: Utc::now(),
222 });
223 while self.findings.len() > MAX_FINDINGS {
224 self.findings.remove(0);
225 }
226 self.increment();
227 }
228
229 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
230 self.decisions.push(Decision {
231 summary: summary.to_string(),
232 rationale: rationale.map(|s| s.to_string()),
233 timestamp: Utc::now(),
234 });
235 while self.decisions.len() > MAX_DECISIONS {
236 self.decisions.remove(0);
237 }
238 self.increment();
239 }
240
241 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
242 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
243 existing.read_count += 1;
244 existing.last_mode = mode.to_string();
245 existing.tokens = tokens;
246 if let Some(r) = file_ref {
247 existing.file_ref = Some(r.to_string());
248 }
249 } else {
250 self.files_touched.push(FileTouched {
251 path: path.to_string(),
252 file_ref: file_ref.map(|s| s.to_string()),
253 read_count: 1,
254 modified: false,
255 last_mode: mode.to_string(),
256 tokens,
257 });
258 while self.files_touched.len() > MAX_FILES {
259 self.files_touched.remove(0);
260 }
261 }
262 self.stats.files_read += 1;
263 self.increment();
264 }
265
266 pub fn mark_modified(&mut self, path: &str) {
267 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
268 existing.modified = true;
269 }
270 self.increment();
271 }
272
273 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
274 self.stats.total_tool_calls += 1;
275 self.stats.total_tokens_saved += tokens_saved;
276 self.stats.total_tokens_input += tokens_input;
277 }
278
279 pub fn record_intent(&mut self, mut intent: IntentRecord) {
280 if intent.occurrences == 0 {
281 intent.occurrences = 1;
282 }
283
284 if let Some(last) = self.intents.last_mut() {
285 if last.fingerprint() == intent.fingerprint() {
286 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
287 last.timestamp = intent.timestamp;
288 match intent.source {
289 IntentSource::Inferred => self.stats.intents_inferred += 1,
290 IntentSource::Explicit => self.stats.intents_explicit += 1,
291 }
292 self.increment();
293 return;
294 }
295 }
296
297 match intent.source {
298 IntentSource::Inferred => self.stats.intents_inferred += 1,
299 IntentSource::Explicit => self.stats.intents_explicit += 1,
300 }
301
302 self.intents.push(intent);
303 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
304 self.intents.remove(0);
305 }
306 self.increment();
307 }
308
309 pub fn record_tool_receipt(
310 &mut self,
311 tool: &str,
312 action: Option<&str>,
313 input_md5: &str,
314 output_md5: &str,
315 agent_id: Option<&str>,
316 client_name: Option<&str>,
317 ) {
318 let now = Utc::now();
319 let mut push = |key: String| {
320 self.evidence.push(EvidenceRecord {
321 kind: EvidenceKind::ToolCall,
322 key,
323 value: None,
324 tool: Some(tool.to_string()),
325 input_md5: Some(input_md5.to_string()),
326 output_md5: Some(output_md5.to_string()),
327 agent_id: agent_id.map(|s| s.to_string()),
328 client_name: client_name.map(|s| s.to_string()),
329 timestamp: now,
330 });
331 };
332
333 push(format!("tool:{tool}"));
334 if let Some(a) = action {
335 push(format!("tool:{tool}:{a}"));
336 }
337 while self.evidence.len() > MAX_EVIDENCE {
338 self.evidence.remove(0);
339 }
340 self.increment();
341 }
342
343 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
344 self.evidence.push(EvidenceRecord {
345 kind: EvidenceKind::Manual,
346 key: key.to_string(),
347 value: value.map(|s| s.to_string()),
348 tool: None,
349 input_md5: None,
350 output_md5: None,
351 agent_id: None,
352 client_name: None,
353 timestamp: Utc::now(),
354 });
355 while self.evidence.len() > MAX_EVIDENCE {
356 self.evidence.remove(0);
357 }
358 self.increment();
359 }
360
361 pub fn has_evidence_key(&self, key: &str) -> bool {
362 self.evidence.iter().any(|e| e.key == key)
363 }
364
365 pub fn record_cache_hit(&mut self) {
366 self.stats.cache_hits += 1;
367 }
368
369 pub fn record_command(&mut self) {
370 self.stats.commands_run += 1;
371 }
372
373 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
376 if let Some(cwd) = explicit_cwd {
377 if !cwd.is_empty() && cwd != "." {
378 return cwd.to_string();
379 }
380 }
381 if let Some(ref cwd) = self.shell_cwd {
382 return cwd.clone();
383 }
384 if let Some(ref root) = self.project_root {
385 return root.clone();
386 }
387 std::env::current_dir()
388 .map(|p| p.to_string_lossy().to_string())
389 .unwrap_or_else(|_| ".".to_string())
390 }
391
392 pub fn update_shell_cwd(&mut self, command: &str) {
396 let base = self.effective_cwd(None);
397 if let Some(new_cwd) = extract_cd_target(command, &base) {
398 let path = std::path::Path::new(&new_cwd);
399 if path.exists() && path.is_dir() {
400 self.shell_cwd = Some(
401 crate::core::pathutil::safe_canonicalize_or_self(path)
402 .to_string_lossy()
403 .to_string(),
404 );
405 }
406 }
407 }
408
409 pub fn format_compact(&self) -> String {
410 let duration = self.updated_at - self.started_at;
411 let hours = duration.num_hours();
412 let mins = duration.num_minutes() % 60;
413 let duration_str = if hours > 0 {
414 format!("{hours}h {mins}m")
415 } else {
416 format!("{mins}m")
417 };
418
419 let mut lines = Vec::new();
420 lines.push(format!(
421 "SESSION v{} | {} | {} calls | {} tok saved",
422 self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
423 ));
424
425 if let Some(ref task) = self.task {
426 let pct = task
427 .progress_pct
428 .map_or(String::new(), |p| format!(" [{p}%]"));
429 lines.push(format!("Task: {}{pct}", task.description));
430 }
431
432 if let Some(ref root) = self.project_root {
433 lines.push(format!("Root: {}", shorten_path(root)));
434 }
435
436 if !self.findings.is_empty() {
437 let items: Vec<String> = self
438 .findings
439 .iter()
440 .rev()
441 .take(5)
442 .map(|f| {
443 let loc = match (&f.file, f.line) {
444 (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
445 (Some(file), None) => shorten_path(file),
446 _ => String::new(),
447 };
448 if loc.is_empty() {
449 f.summary.clone()
450 } else {
451 format!("{loc} \u{2014} {}", f.summary)
452 }
453 })
454 .collect();
455 lines.push(format!(
456 "Findings ({}): {}",
457 self.findings.len(),
458 items.join(" | ")
459 ));
460 }
461
462 if !self.decisions.is_empty() {
463 let items: Vec<&str> = self
464 .decisions
465 .iter()
466 .rev()
467 .take(3)
468 .map(|d| d.summary.as_str())
469 .collect();
470 lines.push(format!("Decisions: {}", items.join(" | ")));
471 }
472
473 if !self.files_touched.is_empty() {
474 let items: Vec<String> = self
475 .files_touched
476 .iter()
477 .rev()
478 .take(10)
479 .map(|f| {
480 let status = if f.modified { "mod" } else { &f.last_mode };
481 let r = f.file_ref.as_deref().unwrap_or("?");
482 format!("[{r} {} {status}]", shorten_path(&f.path))
483 })
484 .collect();
485 lines.push(format!(
486 "Files ({}): {}",
487 self.files_touched.len(),
488 items.join(" ")
489 ));
490 }
491
492 if let Some(ref tests) = self.test_results {
493 lines.push(format!(
494 "Tests: {}/{} pass ({})",
495 tests.passed, tests.total, tests.command
496 ));
497 }
498
499 if !self.next_steps.is_empty() {
500 lines.push(format!("Next: {}", self.next_steps.join(" | ")));
501 }
502
503 lines.join("\n")
504 }
505
506 pub fn build_compaction_snapshot(&self) -> String {
507 const MAX_SNAPSHOT_BYTES: usize = 2048;
508
509 let mut sections: Vec<(u8, String)> = Vec::new();
510
511 if let Some(ref task) = self.task {
512 let pct = task
513 .progress_pct
514 .map_or(String::new(), |p| format!(" [{p}%]"));
515 sections.push((1, format!("<task>{}{pct}</task>", task.description)));
516 }
517
518 if !self.files_touched.is_empty() {
519 let modified: Vec<&str> = self
520 .files_touched
521 .iter()
522 .filter(|f| f.modified)
523 .map(|f| f.path.as_str())
524 .collect();
525 let read_only: Vec<&str> = self
526 .files_touched
527 .iter()
528 .filter(|f| !f.modified)
529 .take(10)
530 .map(|f| f.path.as_str())
531 .collect();
532 let mut files_section = String::new();
533 if !modified.is_empty() {
534 files_section.push_str(&format!("Modified: {}", modified.join(", ")));
535 }
536 if !read_only.is_empty() {
537 if !files_section.is_empty() {
538 files_section.push_str(" | ");
539 }
540 files_section.push_str(&format!("Read: {}", read_only.join(", ")));
541 }
542 sections.push((1, format!("<files>{files_section}</files>")));
543 }
544
545 if !self.decisions.is_empty() {
546 let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
547 sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
548 }
549
550 if !self.findings.is_empty() {
551 let items: Vec<String> = self
552 .findings
553 .iter()
554 .rev()
555 .take(5)
556 .map(|f| f.summary.clone())
557 .collect();
558 sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
559 }
560
561 if !self.progress.is_empty() {
562 let items: Vec<String> = self
563 .progress
564 .iter()
565 .rev()
566 .take(5)
567 .map(|p| {
568 let detail = p.detail.as_deref().unwrap_or("");
569 if detail.is_empty() {
570 p.action.clone()
571 } else {
572 format!("{}: {detail}", p.action)
573 }
574 })
575 .collect();
576 sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
577 }
578
579 if let Some(ref tests) = self.test_results {
580 sections.push((
581 3,
582 format!(
583 "<tests>{}/{} pass ({})</tests>",
584 tests.passed, tests.total, tests.command
585 ),
586 ));
587 }
588
589 if !self.next_steps.is_empty() {
590 sections.push((
591 3,
592 format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
593 ));
594 }
595
596 sections.push((
597 4,
598 format!(
599 "<stats>calls={} saved={}tok</stats>",
600 self.stats.total_tool_calls, self.stats.total_tokens_saved
601 ),
602 ));
603
604 sections.sort_by_key(|(priority, _)| *priority);
605
606 let mut snapshot = String::from("<session_snapshot>\n");
607 for (_, section) in §ions {
608 if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
609 break;
610 }
611 snapshot.push_str(section);
612 snapshot.push('\n');
613 }
614 snapshot.push_str("</session_snapshot>");
615 snapshot
616 }
617
618 pub fn save_compaction_snapshot(&self) -> Result<String, String> {
619 let snapshot = self.build_compaction_snapshot();
620 let dir = sessions_dir().ok_or("cannot determine home directory")?;
621 if !dir.exists() {
622 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
623 }
624 let path = dir.join(format!("{}_snapshot.txt", self.id));
625 std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
626 Ok(snapshot)
627 }
628
629 pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
630 let dir = sessions_dir()?;
631 let path = dir.join(format!("{session_id}_snapshot.txt"));
632 std::fs::read_to_string(&path).ok()
633 }
634
635 pub fn load_latest_snapshot() -> Option<String> {
636 let dir = sessions_dir()?;
637 let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
638 .ok()?
639 .filter_map(|e| e.ok())
640 .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
641 .filter_map(|e| {
642 let meta = e.metadata().ok()?;
643 let modified = meta.modified().ok()?;
644 Some((modified, e.path()))
645 })
646 .collect();
647
648 snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
649 snapshots
650 .first()
651 .and_then(|(_, path)| std::fs::read_to_string(path).ok())
652 }
653
654 pub fn build_resume_block(&self) -> String {
657 let mut parts: Vec<String> = Vec::new();
658
659 if let Some(ref root) = self.project_root {
660 let short = root.rsplit('/').next().unwrap_or(root);
661 parts.push(format!("Project: {short}"));
662 }
663
664 if let Some(ref task) = self.task {
665 let pct = task
666 .progress_pct
667 .map_or(String::new(), |p| format!(" [{p}%]"));
668 parts.push(format!("Task: {}{pct}", task.description));
669 }
670
671 if !self.decisions.is_empty() {
672 let items: Vec<&str> = self
673 .decisions
674 .iter()
675 .rev()
676 .take(5)
677 .map(|d| d.summary.as_str())
678 .collect();
679 parts.push(format!("Decisions: {}", items.join("; ")));
680 }
681
682 if !self.files_touched.is_empty() {
683 let modified: Vec<&str> = self
684 .files_touched
685 .iter()
686 .filter(|f| f.modified)
687 .take(10)
688 .map(|f| f.path.as_str())
689 .collect();
690 if !modified.is_empty() {
691 parts.push(format!("Modified: {}", modified.join(", ")));
692 }
693 }
694
695 if !self.next_steps.is_empty() {
696 let steps: Vec<&str> = self.next_steps.iter().take(3).map(|s| s.as_str()).collect();
697 parts.push(format!("Next: {}", steps.join("; ")));
698 }
699
700 let archives = super::archive::list_entries(Some(&self.id));
701 if !archives.is_empty() {
702 let hints: Vec<String> = archives
703 .iter()
704 .take(5)
705 .map(|a| format!("{}({})", a.id, a.tool))
706 .collect();
707 parts.push(format!("Archives: {}", hints.join(", ")));
708 }
709
710 parts.push(format!(
711 "Stats: {} calls, {} tok saved",
712 self.stats.total_tool_calls, self.stats.total_tokens_saved
713 ));
714
715 format!(
716 "--- SESSION RESUME (post-compaction) ---\n{}\n---",
717 parts.join("\n")
718 )
719 }
720
721 pub fn save(&mut self) -> Result<(), String> {
722 let prepared = self.prepare_save()?;
723 match prepared.write_to_disk() {
724 Ok(()) => Ok(()),
725 Err(e) => {
726 self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
727 Err(e)
728 }
729 }
730 }
731
732 pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
736 let dir = sessions_dir().ok_or("cannot determine home directory")?;
737 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
738 let pointer_json = serde_json::to_string(&LatestPointer {
739 id: self.id.clone(),
740 })
741 .map_err(|e| e.to_string())?;
742 self.stats.unsaved_changes = 0;
743 Ok(PreparedSave {
744 dir,
745 id: self.id.clone(),
746 json,
747 pointer_json,
748 })
749 }
750
751 pub fn load_latest() -> Option<Self> {
752 let dir = sessions_dir()?;
753 let latest_path = dir.join("latest.json");
754 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
755 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
756 Self::load_by_id(&pointer.id)
757 }
758
759 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
760 let dir = sessions_dir()?;
761 let target_root =
762 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
763 let mut latest_match: Option<Self> = None;
764
765 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
766 let path = entry.path();
767 if path.extension().and_then(|e| e.to_str()) != Some("json") {
768 continue;
769 }
770 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
771 continue;
772 }
773
774 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
775 continue;
776 };
777 let Some(session) = Self::load_by_id(id) else {
778 continue;
779 };
780
781 if !session_matches_project_root(&session, &target_root) {
782 continue;
783 }
784
785 if latest_match
786 .as_ref()
787 .is_none_or(|existing| session.updated_at > existing.updated_at)
788 {
789 latest_match = Some(session);
790 }
791 }
792
793 latest_match
794 }
795
796 pub fn load_by_id(id: &str) -> Option<Self> {
797 let dir = sessions_dir()?;
798 let path = dir.join(format!("{id}.json"));
799 let json = std::fs::read_to_string(&path).ok()?;
800 let session: Self = serde_json::from_str(&json).ok()?;
801 Some(normalize_loaded_session(session))
802 }
803
804 pub fn list_sessions() -> Vec<SessionSummary> {
805 let dir = match sessions_dir() {
806 Some(d) => d,
807 None => return Vec::new(),
808 };
809
810 let mut summaries = Vec::new();
811 if let Ok(entries) = std::fs::read_dir(&dir) {
812 for entry in entries.flatten() {
813 let path = entry.path();
814 if path.extension().and_then(|e| e.to_str()) != Some("json") {
815 continue;
816 }
817 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
818 continue;
819 }
820 if let Ok(json) = std::fs::read_to_string(&path) {
821 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
822 summaries.push(SessionSummary {
823 id: session.id,
824 started_at: session.started_at,
825 updated_at: session.updated_at,
826 version: session.version,
827 task: session.task.as_ref().map(|t| t.description.clone()),
828 tool_calls: session.stats.total_tool_calls,
829 tokens_saved: session.stats.total_tokens_saved,
830 });
831 }
832 }
833 }
834 }
835
836 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
837 summaries
838 }
839
840 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
841 let dir = match sessions_dir() {
842 Some(d) => d,
843 None => return 0,
844 };
845
846 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
847 let latest = Self::load_latest().map(|s| s.id);
848 let mut removed = 0u32;
849
850 if let Ok(entries) = std::fs::read_dir(&dir) {
851 for entry in entries.flatten() {
852 let path = entry.path();
853 if path.extension().and_then(|e| e.to_str()) != Some("json") {
854 continue;
855 }
856 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
857 if filename == "latest" || filename.starts_with('.') {
858 continue;
859 }
860 if latest.as_deref() == Some(filename) {
861 continue;
862 }
863 if let Ok(json) = std::fs::read_to_string(&path) {
864 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
865 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
866 removed += 1;
867 }
868 }
869 }
870 }
871 }
872
873 removed
874 }
875}
876
877#[derive(Debug, Clone)]
878pub struct SessionSummary {
879 pub id: String,
880 pub started_at: DateTime<Utc>,
881 pub updated_at: DateTime<Utc>,
882 pub version: u32,
883 pub task: Option<String>,
884 pub tool_calls: u32,
885 pub tokens_saved: u64,
886}
887
888fn sessions_dir() -> Option<PathBuf> {
889 crate::core::data_dir::lean_ctx_data_dir()
890 .ok()
891 .map(|d| d.join("sessions"))
892}
893
894fn generate_session_id() -> String {
895 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
896 let now = Utc::now();
897 let ts = now.format("%Y%m%d-%H%M%S").to_string();
898 let nanos = now.timestamp_subsec_micros();
899 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
900 format!("{ts}-{nanos:06}s{seq}")
901}
902
903fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
906 let first_cmd = command
907 .split("&&")
908 .next()
909 .unwrap_or(command)
910 .split(';')
911 .next()
912 .unwrap_or(command)
913 .trim();
914
915 if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
916 return None;
917 }
918
919 let target = first_cmd.strip_prefix("cd")?.trim();
920 if target.is_empty() || target == "~" {
921 return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
922 }
923
924 let target = target.trim_matches('"').trim_matches('\'');
925 let path = std::path::Path::new(target);
926
927 if path.is_absolute() {
928 Some(target.to_string())
929 } else {
930 let base = std::path::Path::new(base_cwd);
931 let joined = base.join(target).to_string_lossy().to_string();
932 Some(joined.replace('\\', "/"))
933 }
934}
935
936fn shorten_path(path: &str) -> String {
937 let parts: Vec<&str> = path.split('/').collect();
938 if parts.len() <= 2 {
939 return path.to_string();
940 }
941 let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
942 format!("…/{}/{}", last_two[1], last_two[0])
943}
944
945fn normalize_loaded_session(mut session: SessionState) -> SessionState {
946 if matches!(session.project_root.as_deref(), Some(r) if r.trim().is_empty()) {
947 session.project_root = None;
948 }
949 if matches!(session.shell_cwd.as_deref(), Some(c) if c.trim().is_empty()) {
950 session.shell_cwd = None;
951 }
952
953 if let (Some(ref root), Some(ref cwd)) = (&session.project_root, &session.shell_cwd) {
956 let root_p = std::path::Path::new(root);
957 let cwd_p = std::path::Path::new(cwd);
958 let root_looks_real = has_project_marker(root_p);
959 let cwd_looks_real = has_project_marker(cwd_p);
960
961 if !root_looks_real && cwd_looks_real && is_agent_or_temp_dir(root_p) {
962 session.project_root = Some(cwd.clone());
963 }
964 }
965
966 session
967}
968
969fn session_matches_project_root(session: &SessionState, target_root: &std::path::Path) -> bool {
970 if let Some(root) = session.project_root.as_deref() {
971 let root_path =
972 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(root));
973 if root_path == target_root {
974 return true;
975 }
976 if has_project_marker(&root_path) {
977 return false;
978 }
979 }
980
981 if let Some(cwd) = session.shell_cwd.as_deref() {
982 let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(cwd));
983 return cwd_path == target_root || cwd_path.starts_with(target_root);
984 }
985
986 false
987}
988
989fn has_project_marker(dir: &std::path::Path) -> bool {
990 const MARKERS: &[&str] = &[
991 ".git",
992 ".lean-ctx.toml",
993 "Cargo.toml",
994 "package.json",
995 "go.mod",
996 "pyproject.toml",
997 ".planning",
998 ];
999 MARKERS.iter().any(|m| dir.join(m).exists())
1000}
1001
1002fn is_agent_or_temp_dir(dir: &std::path::Path) -> bool {
1003 let s = dir.to_string_lossy();
1004 s.contains("/.claude")
1005 || s.contains("/.codex")
1006 || s.contains("/var/folders/")
1007 || s.contains("/tmp/")
1008 || s.contains("\\.claude")
1009 || s.contains("\\.codex")
1010 || s.contains("\\AppData\\Local\\Temp")
1011 || s.contains("\\Temp\\")
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017
1018 #[test]
1019 fn extract_cd_absolute_path() {
1020 let result = extract_cd_target("cd /usr/local/bin", "/home/user");
1021 assert_eq!(result, Some("/usr/local/bin".to_string()));
1022 }
1023
1024 #[test]
1025 fn extract_cd_relative_path() {
1026 let result = extract_cd_target("cd subdir", "/home/user");
1027 assert_eq!(result, Some("/home/user/subdir".to_string()));
1028 }
1029
1030 #[test]
1031 fn extract_cd_with_chained_command() {
1032 let result = extract_cd_target("cd /tmp && ls", "/home/user");
1033 assert_eq!(result, Some("/tmp".to_string()));
1034 }
1035
1036 #[test]
1037 fn extract_cd_with_semicolon() {
1038 let result = extract_cd_target("cd /tmp; ls", "/home/user");
1039 assert_eq!(result, Some("/tmp".to_string()));
1040 }
1041
1042 #[test]
1043 fn extract_cd_parent_dir() {
1044 let result = extract_cd_target("cd ..", "/home/user/project");
1045 assert_eq!(result, Some("/home/user/project/..".to_string()));
1046 }
1047
1048 #[test]
1049 fn extract_cd_no_cd_returns_none() {
1050 let result = extract_cd_target("ls -la", "/home/user");
1051 assert!(result.is_none());
1052 }
1053
1054 #[test]
1055 fn extract_cd_bare_cd_goes_home() {
1056 let result = extract_cd_target("cd", "/home/user");
1057 assert!(result.is_some());
1058 }
1059
1060 #[test]
1061 fn effective_cwd_explicit_takes_priority() {
1062 let mut session = SessionState::new();
1063 session.project_root = Some("/project".to_string());
1064 session.shell_cwd = Some("/project/src".to_string());
1065 assert_eq!(session.effective_cwd(Some("/explicit")), "/explicit");
1066 }
1067
1068 #[test]
1069 fn effective_cwd_shell_cwd_second_priority() {
1070 let mut session = SessionState::new();
1071 session.project_root = Some("/project".to_string());
1072 session.shell_cwd = Some("/project/src".to_string());
1073 assert_eq!(session.effective_cwd(None), "/project/src");
1074 }
1075
1076 #[test]
1077 fn effective_cwd_project_root_third_priority() {
1078 let mut session = SessionState::new();
1079 session.project_root = Some("/project".to_string());
1080 assert_eq!(session.effective_cwd(None), "/project");
1081 }
1082
1083 #[test]
1084 fn effective_cwd_dot_ignored() {
1085 let mut session = SessionState::new();
1086 session.project_root = Some("/project".to_string());
1087 assert_eq!(session.effective_cwd(Some(".")), "/project");
1088 }
1089
1090 #[test]
1091 fn compaction_snapshot_includes_task() {
1092 let mut session = SessionState::new();
1093 session.set_task("fix auth bug", None);
1094 let snapshot = session.build_compaction_snapshot();
1095 assert!(snapshot.contains("<task>fix auth bug</task>"));
1096 assert!(snapshot.contains("<session_snapshot>"));
1097 assert!(snapshot.contains("</session_snapshot>"));
1098 }
1099
1100 #[test]
1101 fn compaction_snapshot_includes_files() {
1102 let mut session = SessionState::new();
1103 session.touch_file("src/auth.rs", None, "full", 500);
1104 session.files_touched[0].modified = true;
1105 session.touch_file("src/main.rs", None, "map", 100);
1106 let snapshot = session.build_compaction_snapshot();
1107 assert!(snapshot.contains("auth.rs"));
1108 assert!(snapshot.contains("<files>"));
1109 }
1110
1111 #[test]
1112 fn compaction_snapshot_includes_decisions() {
1113 let mut session = SessionState::new();
1114 session.add_decision("Use JWT RS256", None);
1115 let snapshot = session.build_compaction_snapshot();
1116 assert!(snapshot.contains("JWT RS256"));
1117 assert!(snapshot.contains("<decisions>"));
1118 }
1119
1120 #[test]
1121 fn compaction_snapshot_respects_size_limit() {
1122 let mut session = SessionState::new();
1123 session.set_task("a]task", None);
1124 for i in 0..100 {
1125 session.add_finding(
1126 Some(&format!("file{i}.rs")),
1127 Some(i),
1128 &format!("Finding number {i} with some detail text here"),
1129 );
1130 }
1131 let snapshot = session.build_compaction_snapshot();
1132 assert!(snapshot.len() <= 2200);
1133 }
1134
1135 #[test]
1136 fn compaction_snapshot_includes_stats() {
1137 let mut session = SessionState::new();
1138 session.stats.total_tool_calls = 42;
1139 session.stats.total_tokens_saved = 10000;
1140 let snapshot = session.build_compaction_snapshot();
1141 assert!(snapshot.contains("calls=42"));
1142 assert!(snapshot.contains("saved=10000"));
1143 }
1144}