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