1use crate::{Agent, RawReadOptions, Roots, StreamError, read_raw_from};
3use serde::{Deserialize, Serialize};
4use serde_json::value::RawValue;
5use std::{
6 collections::{HashMap, HashSet},
7 fs::{self, File},
8 io::{self, BufReader, Read},
9 path::{Path, PathBuf},
10};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SessionTitleOrigin {
15 SourceTitle,
16 SourceSummary,
17}
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct SessionTitle {
20 pub text: String,
21 pub origin: SessionTitleOrigin,
22}
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum TitleErrorMode {
27 #[default]
29 Strict,
30 Collect,
32}
33
34#[derive(Debug, Clone)]
35pub struct TitleReadOptions {
36 pub max_file_bytes: Option<u64>,
38 pub max_line_bytes: Option<usize>,
41 pub error_mode: TitleErrorMode,
42}
43impl Default for TitleReadOptions {
44 fn default() -> Self {
45 Self {
46 max_file_bytes: Some(200 * 1024 * 1024),
47 max_line_bytes: Some(8 * 1024 * 1024),
48 error_mode: TitleErrorMode::Strict,
49 }
50 }
51}
52
53#[derive(Debug)]
54pub struct TitleReadError {
55 pub path: PathBuf,
56 pub line_no: Option<u64>,
58 pub source: io::Error,
60}
61
62#[derive(Debug, Default)]
63pub struct TitleReadResult {
64 pub titles: HashMap<String, SessionTitle>,
65 pub errors: Vec<TitleReadError>,
67}
68impl TitleReadResult {
69 fn record_error(
70 &mut self,
71 options: &TitleReadOptions,
72 path: &Path,
73 line_no: Option<u64>,
74 source: io::Error,
75 ) -> io::Result<()> {
76 if options.error_mode == TitleErrorMode::Strict {
77 return Err(source);
78 }
79 self.errors.push(TitleReadError {
80 path: path.to_owned(),
81 line_no,
82 source,
83 });
84 Ok(())
85 }
86}
87
88pub fn load_session_titles(
89 agent: Agent,
90 roots: &Roots,
91 ids: &[String],
92) -> io::Result<HashMap<String, SessionTitle>> {
93 load_session_titles_with_options(agent, roots, ids, &TitleReadOptions::default())
94 .map(|result| result.titles)
95}
96
97pub fn load_session_titles_with_options(
102 agent: Agent,
103 roots: &Roots,
104 ids: &[String],
105 options: &TitleReadOptions,
106) -> io::Result<TitleReadResult> {
107 let mut result = TitleReadResult::default();
108 if ids.is_empty() {
109 return Ok(result);
110 }
111 let ids = ids.iter().map(String::as_str).collect::<HashSet<_>>();
112 match agent {
113 Agent::ClaudeCode => {
114 if let Some(root) = &roots.claude {
115 claude(&root.join("projects"), &ids, options, &mut result)?;
116 }
117 }
118 Agent::Codex => {
119 if let Some(root) = &roots.codex {
120 let path = root.join("session_index.jsonl");
121 if let Err(error) = codex(&path, &ids, options, &mut result) {
122 result.record_error(options, &path, None, error)?;
123 }
124 }
125 }
126 }
127 Ok(result)
128}
129
130fn open(path: &Path, options: &TitleReadOptions) -> io::Result<Option<(File, u64)>> {
131 let file = match File::open(path) {
132 Ok(f) => f,
133 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
134 Err(e) => return Err(e),
135 };
136 let size = file.metadata()?.len();
137 if let Some(limit) = options.max_file_bytes
138 && size > limit
139 {
140 return Err(io::Error::new(
141 io::ErrorKind::InvalidData,
142 format!("Title index {} exceeds {limit} byte limit", path.display()),
143 ));
144 }
145 Ok(Some((file, size)))
146}
147fn invalid(path: &Path, error: serde_json::Error) -> io::Error {
148 io::Error::new(
149 io::ErrorKind::InvalidData,
150 format!(
151 "Malformed title index {} at line {}, column {}",
152 path.display(),
153 error.line(),
154 error.column()
155 ),
156 )
157}
158#[derive(Deserialize)]
159struct CodexTitle {
160 id: String,
161 thread_name: String,
162}
163fn codex(
164 path: &Path,
165 ids: &HashSet<&str>,
166 options: &TitleReadOptions,
167 result: &mut TitleReadResult,
168) -> io::Result<()> {
169 let Some((file, size)) = open(path, options)? else {
170 return Ok(());
171 };
172 let mut reader = read_raw_from(
173 BufReader::new(file),
174 &RawReadOptions {
175 stop_at_byte: Some(size),
176 max_read_bytes: options.max_file_bytes,
177 max_line_bytes: options.max_line_bytes,
178 ..Default::default()
179 },
180 )
181 .map_err(io::Error::other)?;
182 while let Some(record) = reader.next() {
183 let record = match record {
184 Ok(record) => record,
185 Err(error) => {
186 let line_no = match &error {
187 StreamError::Line { line_no, .. } => *line_no,
188 _ => reader.next_line_no(),
189 };
190 let source = match error {
191 StreamError::Io(error) => error,
192 StreamError::SnapshotTruncated { .. } => {
193 io::Error::new(io::ErrorKind::UnexpectedEof, error)
194 }
195 _ => io::Error::new(io::ErrorKind::InvalidData, error),
196 };
197 result.record_error(options, path, Some(line_no), source)?;
198 continue;
199 }
200 };
201 if std::str::from_utf8(&record.bytes).is_ok_and(|s| s.trim().is_empty()) {
202 continue;
203 }
204 let row: CodexTitle = match serde_json::from_slice(&record.bytes) {
205 Ok(row) => row,
206 Err(_) => {
207 result.record_error(
208 options,
209 path,
210 Some(record.line_no),
211 io::Error::new(
212 io::ErrorKind::InvalidData,
213 format!(
214 "Malformed title index {} at line {}",
215 path.display(),
216 record.line_no
217 ),
218 ),
219 )?;
220 continue;
221 }
222 };
223 if ids.contains(row.id.as_str()) && !row.thread_name.trim().is_empty() {
224 result.titles.insert(
225 row.id,
226 SessionTitle {
227 text: row.thread_name.trim().into(),
228 origin: SessionTitleOrigin::SourceTitle,
229 },
230 );
231 }
232 }
233 Ok(())
234}
235#[derive(Deserialize)]
236struct ClaudeIndex<'a> {
237 #[serde(borrow)]
238 entries: Vec<&'a RawValue>,
239}
240#[derive(Deserialize)]
241struct ClaudeTitle {
242 #[serde(rename = "sessionId")]
243 session_id: String,
244 summary: Option<String>,
245}
246fn claude_index(
247 path: &Path,
248 ids: &HashSet<&str>,
249 options: &TitleReadOptions,
250 result: &mut TitleReadResult,
251) -> io::Result<()> {
252 let Some((file, size)) = open(path, options)? else {
253 return Ok(());
254 };
255 let mut bytes = Vec::new();
257 file.take(size).read_to_end(&mut bytes)?;
258 if bytes.len() as u64 != size {
259 return Err(io::Error::new(
260 io::ErrorKind::UnexpectedEof,
261 "Title index snapshot was truncated",
262 ));
263 }
264 let index: ClaudeIndex<'_> = serde_json::from_slice(&bytes).map_err(|e| invalid(path, e))?;
265 for (entry_index, raw) in index.entries.into_iter().enumerate() {
266 let row: ClaudeTitle = match serde_json::from_str(raw.get()) {
267 Ok(row) => row,
268 Err(_) => {
269 result.record_error(
270 options,
271 path,
272 None,
273 io::Error::new(
274 io::ErrorKind::InvalidData,
275 format!(
276 "Malformed title index {} at entry {}",
277 path.display(),
278 entry_index + 1
279 ),
280 ),
281 )?;
282 continue;
283 }
284 };
285 if ids.contains(row.session_id.as_str())
286 && let Some(s) = row.summary.filter(|s| !s.trim().is_empty())
287 {
288 result.titles.insert(
289 row.session_id,
290 SessionTitle {
291 text: s.trim().into(),
292 origin: SessionTitleOrigin::SourceSummary,
293 },
294 );
295 }
296 }
297 Ok(())
298}
299fn claude(
300 projects: &Path,
301 ids: &HashSet<&str>,
302 options: &TitleReadOptions,
303 result: &mut TitleReadResult,
304) -> io::Result<()> {
305 let dirs = match fs::read_dir(projects) {
306 Ok(d) => d,
307 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
308 Err(e) => return result.record_error(options, projects, None, e),
309 };
310 for dir in dirs {
311 let dir = match dir {
312 Ok(dir) => dir,
313 Err(e) => {
314 result.record_error(options, projects, None, e)?;
315 continue;
316 }
317 };
318 let ty = match dir.file_type() {
319 Ok(ty) => ty,
320 Err(e) => {
321 result.record_error(options, &dir.path(), None, e)?;
322 continue;
323 }
324 };
325 if !ty.is_dir() {
326 continue;
327 }
328 let path = dir.path().join("sessions-index.json");
329 if let Err(error) = claude_index(&path, ids, options, result) {
330 result.record_error(options, &path, None, error)?;
331 }
332 }
333 Ok(())
334}