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 return Err(io::Error::new(
128 io::ErrorKind::Unsupported,
129 "no native title-index reader for this agent",
130 ));
131 }
132 }
133 Ok(result)
134}
135
136fn open(path: &Path, options: &TitleReadOptions) -> io::Result<Option<(File, u64)>> {
137 let file = match File::open(path) {
138 Ok(f) => f,
139 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
140 Err(e) => return Err(e),
141 };
142 let size = file.metadata()?.len();
143 if let Some(limit) = options.max_file_bytes
144 && size > limit
145 {
146 return Err(io::Error::new(
147 io::ErrorKind::InvalidData,
148 format!("Title index {} exceeds {limit} byte limit", path.display()),
149 ));
150 }
151 Ok(Some((file, size)))
152}
153fn invalid(path: &Path, error: serde_json::Error) -> io::Error {
154 io::Error::new(
155 io::ErrorKind::InvalidData,
156 format!(
157 "Malformed title index {} at line {}, column {}",
158 path.display(),
159 error.line(),
160 error.column()
161 ),
162 )
163}
164#[derive(Deserialize)]
165struct CodexTitle {
166 id: String,
167 thread_name: String,
168}
169fn codex(
170 path: &Path,
171 ids: &HashSet<&str>,
172 options: &TitleReadOptions,
173 result: &mut TitleReadResult,
174) -> io::Result<()> {
175 let Some((file, size)) = open(path, options)? else {
176 return Ok(());
177 };
178 let mut reader = read_raw_from(
179 BufReader::new(file),
180 &RawReadOptions {
181 stop_at_byte: Some(size),
182 max_read_bytes: options.max_file_bytes,
183 max_line_bytes: options.max_line_bytes,
184 ..Default::default()
185 },
186 )
187 .map_err(io::Error::other)?;
188 while let Some(record) = reader.next() {
189 let record = match record {
190 Ok(record) => record,
191 Err(error) => {
192 let line_no = match &error {
193 StreamError::Line { line_no, .. } => *line_no,
194 _ => reader.next_line_no(),
195 };
196 let source = match error {
197 StreamError::Io(error) => error,
198 StreamError::SnapshotTruncated { .. } => {
199 io::Error::new(io::ErrorKind::UnexpectedEof, error)
200 }
201 _ => io::Error::new(io::ErrorKind::InvalidData, error),
202 };
203 result.record_error(options, path, Some(line_no), source)?;
204 continue;
205 }
206 };
207 if std::str::from_utf8(&record.bytes).is_ok_and(|s| s.trim().is_empty()) {
208 continue;
209 }
210 let row: CodexTitle = match serde_json::from_slice(&record.bytes) {
211 Ok(row) => row,
212 Err(_) => {
213 result.record_error(
214 options,
215 path,
216 Some(record.line_no),
217 io::Error::new(
218 io::ErrorKind::InvalidData,
219 format!(
220 "Malformed title index {} at line {}",
221 path.display(),
222 record.line_no
223 ),
224 ),
225 )?;
226 continue;
227 }
228 };
229 if ids.contains(row.id.as_str()) && !row.thread_name.trim().is_empty() {
230 result.titles.insert(
231 row.id,
232 SessionTitle {
233 text: row.thread_name.trim().into(),
234 origin: SessionTitleOrigin::SourceTitle,
235 },
236 );
237 }
238 }
239 Ok(())
240}
241#[derive(Deserialize)]
242struct ClaudeIndex<'a> {
243 #[serde(borrow)]
244 entries: Vec<&'a RawValue>,
245}
246#[derive(Deserialize)]
247struct ClaudeTitle {
248 #[serde(rename = "sessionId")]
249 session_id: String,
250 summary: Option<String>,
251}
252fn claude_index(
253 path: &Path,
254 ids: &HashSet<&str>,
255 options: &TitleReadOptions,
256 result: &mut TitleReadResult,
257) -> io::Result<()> {
258 let Some((file, size)) = open(path, options)? else {
259 return Ok(());
260 };
261 let mut bytes = Vec::new();
263 file.take(size).read_to_end(&mut bytes)?;
264 if bytes.len() as u64 != size {
265 return Err(io::Error::new(
266 io::ErrorKind::UnexpectedEof,
267 "Title index snapshot was truncated",
268 ));
269 }
270 let index: ClaudeIndex<'_> = serde_json::from_slice(&bytes).map_err(|e| invalid(path, e))?;
271 for (entry_index, raw) in index.entries.into_iter().enumerate() {
272 let row: ClaudeTitle = match serde_json::from_str(raw.get()) {
273 Ok(row) => row,
274 Err(_) => {
275 result.record_error(
276 options,
277 path,
278 None,
279 io::Error::new(
280 io::ErrorKind::InvalidData,
281 format!(
282 "Malformed title index {} at entry {}",
283 path.display(),
284 entry_index + 1
285 ),
286 ),
287 )?;
288 continue;
289 }
290 };
291 if ids.contains(row.session_id.as_str())
292 && let Some(s) = row.summary.filter(|s| !s.trim().is_empty())
293 {
294 result.titles.insert(
295 row.session_id,
296 SessionTitle {
297 text: s.trim().into(),
298 origin: SessionTitleOrigin::SourceSummary,
299 },
300 );
301 }
302 }
303 Ok(())
304}
305fn claude(
306 projects: &Path,
307 ids: &HashSet<&str>,
308 options: &TitleReadOptions,
309 result: &mut TitleReadResult,
310) -> io::Result<()> {
311 let dirs = match fs::read_dir(projects) {
312 Ok(d) => d,
313 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
314 Err(e) => return result.record_error(options, projects, None, e),
315 };
316 for dir in dirs {
317 let dir = match dir {
318 Ok(dir) => dir,
319 Err(e) => {
320 result.record_error(options, projects, None, e)?;
321 continue;
322 }
323 };
324 let ty = match dir.file_type() {
325 Ok(ty) => ty,
326 Err(e) => {
327 result.record_error(options, &dir.path(), None, e)?;
328 continue;
329 }
330 };
331 if !ty.is_dir() {
332 continue;
333 }
334 let path = dir.path().join("sessions-index.json");
335 if let Err(error) = claude_index(&path, ids, options, result) {
336 result.record_error(options, &path, None, error)?;
337 }
338 }
339 Ok(())
340}