Skip to main content

nebu_ctx/core/
session.rs

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    /// Returns the effective working directory for shell commands.
346    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd
347    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    /// Updates shell_cwd by detecting `cd` in the command.
365    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
366    /// `cd ..`, and chained commands like `cd foo && ...`.
367    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 &sections {
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    /// Build a compact resume block for post-compaction injection.
669    /// Max ~500 tokens. Includes task, decisions, files, and archive references.
670    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
1008/// Extracts the `cd` target from a command string.
1009/// Handles patterns like `cd /foo`, `cd foo && bar`, `cd ../dir; cmd`, etc.
1010fn 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    // Heal stale project_root caused by agent/temp working directories.
1059    // If project_root doesn't look like a real project root but shell_cwd does, prefer shell_cwd.
1060    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}