1use crate::{SessionEntry, SessionError, SessionInfo, SessionMetadata};
7use chrono::Utc;
8use std::fs::{self, OpenOptions};
9use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
10use std::path::{Path, PathBuf};
11use talos_core::message::Message;
12use uuid::Uuid;
13
14pub use crate::compact_text::CompactTextSessionStore;
15
16pub trait SessionStore: Send + Sync + std::fmt::Debug {
21 fn read_entries(&self, file_path: &Path) -> Result<Vec<SessionEntry>, SessionError>;
23
24 fn append_entry(&self, file_path: &Path, entry: &SessionEntry) -> Result<(), SessionError>;
26
27 fn replace_entries_atomically(
32 &self,
33 file_path: &Path,
34 entries: &[SessionEntry],
35 ) -> Result<(), SessionError>;
36
37 fn read_last_entry_id(&self, file_path: &Path) -> Option<String>;
39
40 fn scan_file(&self, file_path: &Path) -> Result<SessionInfo, SessionError>;
42
43 fn read_bytes(&self, file_path: &Path) -> Result<Vec<u8>, SessionError>;
45
46 fn file_extension(&self) -> &'static str;
48}
49
50#[derive(Debug, Clone, Copy, Default)]
55pub struct JsonlSessionStore;
56
57impl SessionStore for JsonlSessionStore {
58 fn read_entries(&self, file_path: &Path) -> Result<Vec<SessionEntry>, SessionError> {
59 read_entries_from_path(file_path)
60 }
61
62 fn append_entry(&self, file_path: &Path, entry: &SessionEntry) -> Result<(), SessionError> {
63 let line =
64 serde_json::to_string(entry).map_err(|e| SessionError::InvalidJson(e.to_string()))?;
65
66 if !file_path.exists()
67 && let Some(parent) = file_path.parent()
68 {
69 fs::create_dir_all(parent)?;
70 }
71
72 let mut file = OpenOptions::new()
73 .create(true)
74 .append(true)
75 .open(file_path)?;
76 writeln!(file, "{line}")?;
77
78 Ok(())
79 }
80
81 fn replace_entries_atomically(
82 &self,
83 file_path: &Path,
84 entries: &[SessionEntry],
85 ) -> Result<(), SessionError> {
86 let parent = file_path.parent().ok_or_else(|| {
87 SessionError::ParseError("session file has no parent directory".into())
88 })?;
89 fs::create_dir_all(parent)?;
90 let temporary = temporary_sibling(file_path);
91 let mut file = OpenOptions::new()
92 .create_new(true)
93 .write(true)
94 .open(&temporary)?;
95 for entry in entries {
96 let line = serde_json::to_string(entry)
97 .map_err(|error| SessionError::InvalidJson(error.to_string()))?;
98 writeln!(file, "{line}")?;
99 }
100 file.sync_all()?;
101 drop(file);
102 fs::rename(&temporary, file_path)?;
103 Ok(())
104 }
105
106 fn read_last_entry_id(&self, file_path: &Path) -> Option<String> {
107 read_last_entry_id(file_path)
108 }
109
110 fn scan_file(&self, file_path: &Path) -> Result<SessionInfo, SessionError> {
111 let file = fs::File::open(file_path)?;
112 let metadata = file.metadata()?;
113 let timestamp = metadata
114 .modified()
115 .ok()
116 .map(chrono::DateTime::<Utc>::from)
117 .unwrap_or_else(Utc::now);
118
119 let id = file_path
120 .file_stem()
121 .and_then(|s| s.to_str())
122 .and_then(|s| Uuid::parse_str(s).ok())
123 .unwrap_or_else(Uuid::nil);
124
125 let reader = BufReader::new(file);
126 let mut count = 0;
127 let mut last_preview = String::new();
128
129 for line in reader.lines() {
130 let line = line?;
131 if line.is_empty() {
132 continue;
133 }
134
135 if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) {
136 count += 1;
137 last_preview = crate::jsonl::preview_text(&entry.content);
138 continue;
139 }
140
141 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line)
142 && value.get("type").and_then(|t| t.as_str()) == Some("message")
143 && let Some(data) = value.get("data")
144 && let Ok(msg) = serde_json::from_value::<Message>(data.clone())
145 {
146 count += 1;
147 let (_, content) = crate::jsonl::message_parts(&msg);
148 last_preview = crate::jsonl::preview_text(&content);
149 }
150 }
151
152 Ok(SessionInfo {
153 id,
154 project: String::new(),
155 workspace_root: String::new(),
156 last_message_preview: last_preview,
157 timestamp,
158 message_count: count,
159 })
160 }
161
162 fn read_bytes(&self, file_path: &Path) -> Result<Vec<u8>, SessionError> {
163 std::fs::read(file_path).map_err(SessionError::IoError)
164 }
165
166 fn file_extension(&self) -> &'static str {
167 "jsonl"
168 }
169}
170
171pub(crate) fn temporary_sibling(file_path: &Path) -> PathBuf {
173 let file_name = file_path
174 .file_name()
175 .and_then(|name| name.to_str())
176 .unwrap_or("session");
177 file_path.with_file_name(format!(".{file_name}.{}.tmp", Uuid::new_v4()))
178}
179
180fn read_last_entry_id(path: &Path) -> Option<String> {
181 let mut file = fs::File::open(path).ok()?;
182 let file_size = file.metadata().ok()?.len();
183 if file_size == 0 {
184 return None;
185 }
186 let read_size = std::cmp::min(file_size, 8192) as usize;
187 let seek_pos = file_size.saturating_sub(read_size as u64);
188 file.seek(SeekFrom::Start(seek_pos)).ok()?;
189 let mut buf = vec![0u8; read_size];
190 file.read_exact(&mut buf).ok()?;
191 let text = String::from_utf8_lossy(&buf);
192 let last_line = text.lines().rev().find(|l| !l.is_empty())?;
193 let entry: SessionEntry = serde_json::from_str(last_line).ok()?;
194 Some(entry.id)
195}
196
197fn read_entries_from_path(path: &Path) -> Result<Vec<SessionEntry>, SessionError> {
198 if !path.exists() {
199 return Ok(Vec::new());
200 }
201
202 let file = fs::File::open(path)?;
203 let reader = BufReader::new(file);
204 let mut entries = Vec::new();
205 let mut synthetic_counter: u64 = 0;
206
207 for line in reader.lines() {
208 let line = line?;
209 if line.is_empty() {
210 continue;
211 }
212
213 if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) {
214 entries.push(entry);
215 continue;
216 }
217
218 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line)
219 && value.get("type").and_then(|t| t.as_str()) == Some("message")
220 && let Some(data) = value.get("data")
221 && let Ok(msg) = serde_json::from_value::<Message>(data.clone())
222 {
223 let (role, content) = crate::jsonl::message_parts(&msg);
224 let id = format!("synthetic-{synthetic_counter}");
225 let parent_id = if synthetic_counter > 0 {
226 Some(format!("synthetic-{}", synthetic_counter - 1))
227 } else {
228 None
229 };
230
231 entries.push(SessionEntry {
232 id,
233 parent_id,
234 timestamp: Utc::now(),
235 role,
236 content,
237 metadata: SessionMetadata::default(),
238 });
239 synthetic_counter += 1;
240 }
241 }
243
244 Ok(entries)
245}