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