1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_FINDINGS: usize = 20;
6const MAX_DECISIONS: usize = 10;
7const MAX_FILES: usize = 50;
8#[allow(dead_code)]
9const MAX_PROGRESS: usize = 30;
10#[allow(dead_code)]
11const MAX_NEXT_STEPS: usize = 10;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14#[derive(Serialize, Deserialize, Clone, Debug)]
15pub struct SessionState {
16 pub id: String,
17 pub version: u32,
18 pub started_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20 pub project_root: Option<String>,
21 pub task: Option<TaskInfo>,
22 pub findings: Vec<Finding>,
23 pub decisions: Vec<Decision>,
24 pub files_touched: Vec<FileTouched>,
25 pub test_results: Option<TestSnapshot>,
26 pub progress: Vec<ProgressEntry>,
27 pub next_steps: Vec<String>,
28 pub stats: SessionStats,
29}
30
31#[derive(Serialize, Deserialize, Clone, Debug)]
32pub struct TaskInfo {
33 pub description: String,
34 pub intent: Option<String>,
35 pub progress_pct: Option<u8>,
36}
37
38#[derive(Serialize, Deserialize, Clone, Debug)]
39pub struct Finding {
40 pub file: Option<String>,
41 pub line: Option<u32>,
42 pub summary: String,
43 pub timestamp: DateTime<Utc>,
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug)]
47pub struct Decision {
48 pub summary: String,
49 pub rationale: Option<String>,
50 pub timestamp: DateTime<Utc>,
51}
52
53#[derive(Serialize, Deserialize, Clone, Debug)]
54pub struct FileTouched {
55 pub path: String,
56 pub file_ref: Option<String>,
57 pub read_count: u32,
58 pub modified: bool,
59 pub last_mode: String,
60 pub tokens: usize,
61}
62
63#[derive(Serialize, Deserialize, Clone, Debug)]
64pub struct TestSnapshot {
65 pub command: String,
66 pub passed: u32,
67 pub failed: u32,
68 pub total: u32,
69 pub timestamp: DateTime<Utc>,
70}
71
72#[derive(Serialize, Deserialize, Clone, Debug)]
73pub struct ProgressEntry {
74 pub action: String,
75 pub detail: Option<String>,
76 pub timestamp: DateTime<Utc>,
77}
78
79#[derive(Serialize, Deserialize, Clone, Debug, Default)]
80pub struct SessionStats {
81 pub total_tool_calls: u32,
82 pub total_tokens_saved: u64,
83 pub total_tokens_input: u64,
84 pub cache_hits: u32,
85 pub files_read: u32,
86 pub commands_run: u32,
87 pub unsaved_changes: u32,
88}
89
90#[derive(Serialize, Deserialize, Clone, Debug)]
91struct LatestPointer {
92 id: String,
93}
94
95impl Default for SessionState {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl SessionState {
102 pub fn new() -> Self {
103 let now = Utc::now();
104 Self {
105 id: generate_session_id(),
106 version: 0,
107 started_at: now,
108 updated_at: now,
109 project_root: None,
110 task: None,
111 findings: Vec::new(),
112 decisions: Vec::new(),
113 files_touched: Vec::new(),
114 test_results: None,
115 progress: Vec::new(),
116 next_steps: Vec::new(),
117 stats: SessionStats::default(),
118 }
119 }
120
121 pub fn increment(&mut self) {
122 self.version += 1;
123 self.updated_at = Utc::now();
124 self.stats.unsaved_changes += 1;
125 }
126
127 pub fn should_save(&self) -> bool {
128 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
129 }
130
131 pub fn mark_initialized(&mut self) {
132 if self.version == 0 {
133 self.version = 1;
134 self.updated_at = chrono::Utc::now();
135 self.project_root = std::env::current_dir()
136 .ok()
137 .map(|p| p.to_string_lossy().to_string());
138 }
139 }
140
141 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
142 self.task = Some(TaskInfo {
143 description: description.to_string(),
144 intent: intent.map(|s| s.to_string()),
145 progress_pct: None,
146 });
147 self.increment();
148 }
149
150 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
151 self.findings.push(Finding {
152 file: file.map(|s| s.to_string()),
153 line,
154 summary: summary.to_string(),
155 timestamp: Utc::now(),
156 });
157 while self.findings.len() > MAX_FINDINGS {
158 self.findings.remove(0);
159 }
160 self.increment();
161 }
162
163 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
164 self.decisions.push(Decision {
165 summary: summary.to_string(),
166 rationale: rationale.map(|s| s.to_string()),
167 timestamp: Utc::now(),
168 });
169 while self.decisions.len() > MAX_DECISIONS {
170 self.decisions.remove(0);
171 }
172 self.increment();
173 }
174
175 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
176 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
177 existing.read_count += 1;
178 existing.last_mode = mode.to_string();
179 existing.tokens = tokens;
180 if let Some(r) = file_ref {
181 existing.file_ref = Some(r.to_string());
182 }
183 } else {
184 self.files_touched.push(FileTouched {
185 path: path.to_string(),
186 file_ref: file_ref.map(|s| s.to_string()),
187 read_count: 1,
188 modified: false,
189 last_mode: mode.to_string(),
190 tokens,
191 });
192 while self.files_touched.len() > MAX_FILES {
193 self.files_touched.remove(0);
194 }
195 }
196 self.stats.files_read += 1;
197 self.increment();
198 }
199
200 pub fn mark_modified(&mut self, path: &str) {
201 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
202 existing.modified = true;
203 }
204 self.increment();
205 }
206
207 #[allow(dead_code)]
208 pub fn set_test_results(&mut self, command: &str, passed: u32, failed: u32, total: u32) {
209 self.test_results = Some(TestSnapshot {
210 command: command.to_string(),
211 passed,
212 failed,
213 total,
214 timestamp: Utc::now(),
215 });
216 self.increment();
217 }
218
219 #[allow(dead_code)]
220 pub fn add_progress(&mut self, action: &str, detail: Option<&str>) {
221 self.progress.push(ProgressEntry {
222 action: action.to_string(),
223 detail: detail.map(|s| s.to_string()),
224 timestamp: Utc::now(),
225 });
226 while self.progress.len() > MAX_PROGRESS {
227 self.progress.remove(0);
228 }
229 self.increment();
230 }
231
232 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
233 self.stats.total_tool_calls += 1;
234 self.stats.total_tokens_saved += tokens_saved;
235 self.stats.total_tokens_input += tokens_input;
236 }
237
238 pub fn record_cache_hit(&mut self) {
239 self.stats.cache_hits += 1;
240 }
241
242 pub fn record_command(&mut self) {
243 self.stats.commands_run += 1;
244 }
245
246 pub fn format_compact(&self) -> String {
247 let duration = self.updated_at - self.started_at;
248 let hours = duration.num_hours();
249 let mins = duration.num_minutes() % 60;
250 let duration_str = if hours > 0 {
251 format!("{hours}h {mins}m")
252 } else {
253 format!("{mins}m")
254 };
255
256 let mut lines = Vec::new();
257 lines.push(format!(
258 "SESSION v{} | {} | {} calls | {} tok saved",
259 self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
260 ));
261
262 if let Some(ref task) = self.task {
263 let pct = task
264 .progress_pct
265 .map_or(String::new(), |p| format!(" [{p}%]"));
266 lines.push(format!("Task: {}{pct}", task.description));
267 }
268
269 if let Some(ref root) = self.project_root {
270 lines.push(format!("Root: {}", shorten_path(root)));
271 }
272
273 if !self.findings.is_empty() {
274 let items: Vec<String> = self
275 .findings
276 .iter()
277 .rev()
278 .take(5)
279 .map(|f| {
280 let loc = match (&f.file, f.line) {
281 (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
282 (Some(file), None) => shorten_path(file),
283 _ => String::new(),
284 };
285 if loc.is_empty() {
286 f.summary.clone()
287 } else {
288 format!("{loc} \u{2014} {}", f.summary)
289 }
290 })
291 .collect();
292 lines.push(format!(
293 "Findings ({}): {}",
294 self.findings.len(),
295 items.join(" | ")
296 ));
297 }
298
299 if !self.decisions.is_empty() {
300 let items: Vec<&str> = self
301 .decisions
302 .iter()
303 .rev()
304 .take(3)
305 .map(|d| d.summary.as_str())
306 .collect();
307 lines.push(format!("Decisions: {}", items.join(" | ")));
308 }
309
310 if !self.files_touched.is_empty() {
311 let items: Vec<String> = self
312 .files_touched
313 .iter()
314 .rev()
315 .take(10)
316 .map(|f| {
317 let status = if f.modified { "mod" } else { &f.last_mode };
318 let r = f.file_ref.as_deref().unwrap_or("?");
319 format!("[{r} {} {status}]", shorten_path(&f.path))
320 })
321 .collect();
322 lines.push(format!(
323 "Files ({}): {}",
324 self.files_touched.len(),
325 items.join(" ")
326 ));
327 }
328
329 if let Some(ref tests) = self.test_results {
330 lines.push(format!(
331 "Tests: {}/{} pass ({})",
332 tests.passed, tests.total, tests.command
333 ));
334 }
335
336 if !self.next_steps.is_empty() {
337 lines.push(format!("Next: {}", self.next_steps.join(" | ")));
338 }
339
340 lines.join("\n")
341 }
342
343 pub fn build_compaction_snapshot(&self) -> String {
344 const MAX_SNAPSHOT_BYTES: usize = 2048;
345
346 let mut sections: Vec<(u8, String)> = Vec::new();
347
348 if let Some(ref task) = self.task {
349 let pct = task
350 .progress_pct
351 .map_or(String::new(), |p| format!(" [{p}%]"));
352 sections.push((1, format!("<task>{}{pct}</task>", task.description)));
353 }
354
355 if !self.files_touched.is_empty() {
356 let modified: Vec<&str> = self
357 .files_touched
358 .iter()
359 .filter(|f| f.modified)
360 .map(|f| f.path.as_str())
361 .collect();
362 let read_only: Vec<&str> = self
363 .files_touched
364 .iter()
365 .filter(|f| !f.modified)
366 .take(10)
367 .map(|f| f.path.as_str())
368 .collect();
369 let mut files_section = String::new();
370 if !modified.is_empty() {
371 files_section.push_str(&format!("Modified: {}", modified.join(", ")));
372 }
373 if !read_only.is_empty() {
374 if !files_section.is_empty() {
375 files_section.push_str(" | ");
376 }
377 files_section.push_str(&format!("Read: {}", read_only.join(", ")));
378 }
379 sections.push((1, format!("<files>{files_section}</files>")));
380 }
381
382 if !self.decisions.is_empty() {
383 let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
384 sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
385 }
386
387 if !self.findings.is_empty() {
388 let items: Vec<String> = self
389 .findings
390 .iter()
391 .rev()
392 .take(5)
393 .map(|f| f.summary.clone())
394 .collect();
395 sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
396 }
397
398 if !self.progress.is_empty() {
399 let items: Vec<String> = self
400 .progress
401 .iter()
402 .rev()
403 .take(5)
404 .map(|p| {
405 let detail = p.detail.as_deref().unwrap_or("");
406 if detail.is_empty() {
407 p.action.clone()
408 } else {
409 format!("{}: {detail}", p.action)
410 }
411 })
412 .collect();
413 sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
414 }
415
416 if let Some(ref tests) = self.test_results {
417 sections.push((
418 3,
419 format!(
420 "<tests>{}/{} pass ({})</tests>",
421 tests.passed, tests.total, tests.command
422 ),
423 ));
424 }
425
426 if !self.next_steps.is_empty() {
427 sections.push((
428 3,
429 format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
430 ));
431 }
432
433 sections.push((
434 4,
435 format!(
436 "<stats>calls={} saved={}tok</stats>",
437 self.stats.total_tool_calls, self.stats.total_tokens_saved
438 ),
439 ));
440
441 sections.sort_by_key(|(priority, _)| *priority);
442
443 let mut snapshot = String::from("<session_snapshot>\n");
444 for (_, section) in §ions {
445 if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
446 break;
447 }
448 snapshot.push_str(section);
449 snapshot.push('\n');
450 }
451 snapshot.push_str("</session_snapshot>");
452 snapshot
453 }
454
455 pub fn save_compaction_snapshot(&self) -> Result<String, String> {
456 let snapshot = self.build_compaction_snapshot();
457 let dir = sessions_dir().ok_or("cannot determine home directory")?;
458 if !dir.exists() {
459 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
460 }
461 let path = dir.join(format!("{}_snapshot.txt", self.id));
462 std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
463 Ok(snapshot)
464 }
465
466 pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
467 let dir = sessions_dir()?;
468 let path = dir.join(format!("{session_id}_snapshot.txt"));
469 std::fs::read_to_string(&path).ok()
470 }
471
472 pub fn load_latest_snapshot() -> Option<String> {
473 let dir = sessions_dir()?;
474 let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
475 .ok()?
476 .filter_map(|e| e.ok())
477 .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
478 .filter_map(|e| {
479 let meta = e.metadata().ok()?;
480 let modified = meta.modified().ok()?;
481 Some((modified, e.path()))
482 })
483 .collect();
484
485 snapshots.sort_by(|a, b| b.0.cmp(&a.0));
486 snapshots
487 .first()
488 .and_then(|(_, path)| std::fs::read_to_string(path).ok())
489 }
490
491 pub fn save(&mut self) -> Result<(), String> {
492 let dir = sessions_dir().ok_or("cannot determine home directory")?;
493 if !dir.exists() {
494 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
495 }
496
497 let path = dir.join(format!("{}.json", self.id));
498 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
499
500 let tmp = dir.join(format!(".{}.json.tmp", self.id));
501 std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
502 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
503
504 let pointer = LatestPointer {
505 id: self.id.clone(),
506 };
507 let pointer_json = serde_json::to_string(&pointer).map_err(|e| e.to_string())?;
508 let latest_path = dir.join("latest.json");
509 let latest_tmp = dir.join(".latest.json.tmp");
510 std::fs::write(&latest_tmp, &pointer_json).map_err(|e| e.to_string())?;
511 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
512
513 self.stats.unsaved_changes = 0;
514 Ok(())
515 }
516
517 pub fn load_latest() -> Option<Self> {
518 let dir = sessions_dir()?;
519 let latest_path = dir.join("latest.json");
520 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
521 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
522 Self::load_by_id(&pointer.id)
523 }
524
525 pub fn load_by_id(id: &str) -> Option<Self> {
526 let dir = sessions_dir()?;
527 let path = dir.join(format!("{id}.json"));
528 let json = std::fs::read_to_string(&path).ok()?;
529 serde_json::from_str(&json).ok()
530 }
531
532 pub fn list_sessions() -> Vec<SessionSummary> {
533 let dir = match sessions_dir() {
534 Some(d) => d,
535 None => return Vec::new(),
536 };
537
538 let mut summaries = Vec::new();
539 if let Ok(entries) = std::fs::read_dir(&dir) {
540 for entry in entries.flatten() {
541 let path = entry.path();
542 if path.extension().and_then(|e| e.to_str()) != Some("json") {
543 continue;
544 }
545 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
546 continue;
547 }
548 if let Ok(json) = std::fs::read_to_string(&path) {
549 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
550 summaries.push(SessionSummary {
551 id: session.id,
552 started_at: session.started_at,
553 updated_at: session.updated_at,
554 version: session.version,
555 task: session.task.as_ref().map(|t| t.description.clone()),
556 tool_calls: session.stats.total_tool_calls,
557 tokens_saved: session.stats.total_tokens_saved,
558 });
559 }
560 }
561 }
562 }
563
564 summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
565 summaries
566 }
567
568 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
569 let dir = match sessions_dir() {
570 Some(d) => d,
571 None => return 0,
572 };
573
574 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
575 let latest = Self::load_latest().map(|s| s.id);
576 let mut removed = 0u32;
577
578 if let Ok(entries) = std::fs::read_dir(&dir) {
579 for entry in entries.flatten() {
580 let path = entry.path();
581 if path.extension().and_then(|e| e.to_str()) != Some("json") {
582 continue;
583 }
584 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
585 if filename == "latest" || filename.starts_with('.') {
586 continue;
587 }
588 if latest.as_deref() == Some(filename) {
589 continue;
590 }
591 if let Ok(json) = std::fs::read_to_string(&path) {
592 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
593 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
594 removed += 1;
595 }
596 }
597 }
598 }
599 }
600
601 removed
602 }
603}
604
605#[derive(Debug, Clone)]
606#[allow(dead_code)]
607pub struct SessionSummary {
608 pub id: String,
609 pub started_at: DateTime<Utc>,
610 pub updated_at: DateTime<Utc>,
611 pub version: u32,
612 pub task: Option<String>,
613 pub tool_calls: u32,
614 pub tokens_saved: u64,
615}
616
617fn sessions_dir() -> Option<PathBuf> {
618 dirs::home_dir().map(|h| h.join(".lean-ctx").join("sessions"))
619}
620
621fn generate_session_id() -> String {
622 let now = Utc::now();
623 let ts = now.format("%Y%m%d-%H%M%S").to_string();
624 let random: u32 = (std::time::SystemTime::now()
625 .duration_since(std::time::UNIX_EPOCH)
626 .unwrap_or_default()
627 .subsec_nanos())
628 % 10000;
629 format!("{ts}-{random:04}")
630}
631
632fn shorten_path(path: &str) -> String {
633 let parts: Vec<&str> = path.split('/').collect();
634 if parts.len() <= 2 {
635 return path.to_string();
636 }
637 let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
638 format!("…/{}/{}", last_two[1], last_two[0])
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn compaction_snapshot_includes_task() {
647 let mut session = SessionState::new();
648 session.set_task("fix auth bug", None);
649 let snapshot = session.build_compaction_snapshot();
650 assert!(snapshot.contains("<task>fix auth bug</task>"));
651 assert!(snapshot.contains("<session_snapshot>"));
652 assert!(snapshot.contains("</session_snapshot>"));
653 }
654
655 #[test]
656 fn compaction_snapshot_includes_files() {
657 let mut session = SessionState::new();
658 session.touch_file("src/auth.rs", None, "full", 500);
659 session.files_touched[0].modified = true;
660 session.touch_file("src/main.rs", None, "map", 100);
661 let snapshot = session.build_compaction_snapshot();
662 assert!(snapshot.contains("auth.rs"));
663 assert!(snapshot.contains("<files>"));
664 }
665
666 #[test]
667 fn compaction_snapshot_includes_decisions() {
668 let mut session = SessionState::new();
669 session.add_decision("Use JWT RS256", None);
670 let snapshot = session.build_compaction_snapshot();
671 assert!(snapshot.contains("JWT RS256"));
672 assert!(snapshot.contains("<decisions>"));
673 }
674
675 #[test]
676 fn compaction_snapshot_respects_size_limit() {
677 let mut session = SessionState::new();
678 session.set_task("a]task", None);
679 for i in 0..100 {
680 session.add_finding(
681 Some(&format!("file{i}.rs")),
682 Some(i),
683 &format!("Finding number {i} with some detail text here"),
684 );
685 }
686 let snapshot = session.build_compaction_snapshot();
687 assert!(snapshot.len() <= 2200);
688 }
689
690 #[test]
691 fn compaction_snapshot_includes_stats() {
692 let mut session = SessionState::new();
693 session.stats.total_tool_calls = 42;
694 session.stats.total_tokens_saved = 10000;
695 let snapshot = session.build_compaction_snapshot();
696 assert!(snapshot.contains("calls=42"));
697 assert!(snapshot.contains("saved=10000"));
698 }
699}