Skip to main content

baml_agent/session/
store.rs

1//! Session struct: JSONL persistence, history, context trimming.
2
3use std::fs::OpenOptions;
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use super::format::{make_persisted, parse_entry};
8use super::traits::{AgentMessage, EntryType, MessageRole};
9
10/// Session manager: JSONL persistence, history access, context trimming.
11///
12/// Uses Claude Code compatible JSONL format with UUID v7 (time-sortable).
13pub struct Session<M: AgentMessage> {
14    messages: Vec<M>,
15    session_file: PathBuf,
16    session_id: String,
17    last_uuid: Option<String>,
18    max_history: usize,
19}
20
21impl<M: AgentMessage> Session<M> {
22    /// Create a new session with a fresh JSONL file.
23    ///
24    /// Creates the session directory if it doesn't exist.
25    /// Returns an error if the directory cannot be created.
26    pub fn new(session_dir: &str, max_history: usize) -> std::io::Result<Self> {
27        std::fs::create_dir_all(session_dir)?;
28        let session_id = uuid::Uuid::now_v7().to_string();
29        let session_file = PathBuf::from(format!("{}/{}.jsonl", session_dir, session_id));
30        Ok(Self {
31            messages: Vec::new(),
32            session_file,
33            session_id,
34            last_uuid: None,
35            max_history,
36        })
37    }
38
39    /// Resume from a specific session file.
40    pub fn resume(path: &Path, _session_dir: &str, max_history: usize) -> Self {
41        let (messages, session_id, last_uuid) = Self::load_file(path);
42        Self {
43            messages,
44            session_file: path.to_path_buf(),
45            session_id,
46            last_uuid,
47            max_history,
48        }
49    }
50
51    /// Resume the most recent session in the session directory.
52    pub fn resume_last(session_dir: &str, max_history: usize) -> Option<Self> {
53        let last = Self::find_last_session(session_dir)?;
54        Some(Self::resume(&last, session_dir, max_history))
55    }
56
57    /// Push a message, persist to JSONL, return ref.
58    pub fn push(&mut self, role: <M as AgentMessage>::Role, content: String) -> &M {
59        let msg = M::new(role, content);
60        self.messages.push(msg);
61        self.persist_last();
62        self.messages.last().expect("just pushed")
63    }
64
65    /// Push a pre-built message.
66    pub fn push_msg(&mut self, msg: M) {
67        self.messages.push(msg);
68        self.persist_last();
69    }
70
71    /// Access messages.
72    pub fn messages(&self) -> &[M] {
73        &self.messages
74    }
75
76    /// Mutable access to messages (for external trimming).
77    pub fn messages_mut(&mut self) -> &mut Vec<M> {
78        &mut self.messages
79    }
80
81    pub fn is_empty(&self) -> bool {
82        self.messages.is_empty()
83    }
84
85    pub fn len(&self) -> usize {
86        self.messages.len()
87    }
88
89    pub fn session_file(&self) -> &Path {
90        &self.session_file
91    }
92
93    pub fn session_id(&self) -> &str {
94        &self.session_id
95    }
96
97    /// Trim history to fit context window.
98    ///
99    /// Preserves system messages and the most recent non-system messages.
100    /// Inserts a "[N earlier messages trimmed]" system notice.
101    /// Returns the number of trimmed messages (0 if no trimming needed).
102    pub fn trim(&mut self) -> usize {
103        if self.messages.len() <= self.max_history {
104            return 0;
105        }
106
107        let system_msgs: Vec<M> = self
108            .messages
109            .iter()
110            .filter(|m| m.role().is_system())
111            .cloned()
112            .collect();
113
114        let non_system: Vec<M> = self
115            .messages
116            .iter()
117            .filter(|m| !m.role().is_system())
118            .cloned()
119            .collect();
120
121        let keep = self.max_history.saturating_sub(system_msgs.len());
122        let skip = non_system.len().saturating_sub(keep);
123
124        if skip == 0 {
125            return 0;
126        }
127
128        let mut trimmed = system_msgs;
129        trimmed.push(M::new(
130            <M as AgentMessage>::Role::system(),
131            format!("[{} earlier messages trimmed]", skip),
132        ));
133        trimmed.extend(non_system.into_iter().skip(skip));
134        self.messages = trimmed;
135        skip
136    }
137
138    // --- Private ---
139
140    fn persist_last(&mut self) {
141        let Some(msg) = self.messages.last() else {
142            return;
143        };
144        let Some(entry_type) = EntryType::parse(msg.role().as_str()) else {
145            return;
146        };
147        let persisted = make_persisted(
148            entry_type,
149            msg.content(),
150            &self.session_id,
151            self.last_uuid.as_deref(),
152        );
153        self.last_uuid = Some(persisted.uuid.clone());
154        let Ok(json) = serde_json::to_string(&persisted) else {
155            return;
156        };
157        let Ok(mut f) = OpenOptions::new()
158            .create(true)
159            .append(true)
160            .open(&self.session_file)
161        else {
162            return;
163        };
164        let _ = writeln!(f, "{}", json);
165    }
166
167    /// Load a session file, supporting both new and legacy formats.
168    /// Returns (messages, session_id, last_uuid).
169    fn load_file(path: &Path) -> (Vec<M>, String, Option<String>) {
170        let Ok(file) = std::fs::File::open(path) else {
171            return (vec![], uuid::Uuid::now_v7().to_string(), None);
172        };
173
174        let mut messages = Vec::new();
175        let mut session_id = None;
176        let mut last_uuid = None;
177
178        for line in BufReader::new(file).lines().map_while(Result::ok) {
179            let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
180                continue;
181            };
182
183            if let Some(sid) = value["sessionId"].as_str() {
184                session_id = Some(sid.to_string());
185            }
186            if let Some(uid) = value["uuid"].as_str() {
187                last_uuid = Some(uid.to_string());
188            }
189
190            if let Some((entry_type, content)) = parse_entry(&value) {
191                messages.push(M::new(
192                    entry_type.into_role::<<M as AgentMessage>::Role>(),
193                    content,
194                ));
195            }
196        }
197
198        let sid = session_id.unwrap_or_else(|| {
199            path.file_stem()
200                .and_then(|s| s.to_str())
201                .map(String::from)
202                .unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
203        });
204
205        (messages, sid, last_uuid)
206    }
207
208    fn find_last_session(dir: &str) -> Option<PathBuf> {
209        let mut entries: Vec<_> = std::fs::read_dir(dir)
210            .ok()?
211            .filter_map(|e| e.ok())
212            .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
213            .collect();
214        entries.sort_by_key(|e| e.file_name());
215        entries.last().map(|e| e.path())
216    }
217}