Skip to main content

agent_sessions/
titles.rs

1//! Explicit index metadata; never falls back to prompt/response bodies.
2use 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    /// Stop at the first error, preserving the original title API's contract.
28    #[default]
29    Strict,
30    /// Return valid titles together with errors. Results may be incomplete.
31    Collect,
32}
33
34#[derive(Debug, Clone)]
35pub struct TitleReadOptions {
36    /// Per-index byte budget, checked before allocation. None disables it.
37    pub max_file_bytes: Option<u64>,
38    /// Codex JSONL record budget including the delimiter. Claude's JSON document
39    /// is bounded by max_file_bytes, not by its physical line layout.
40    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    /// Physical Codex JSONL line; None for file errors or Claude JSON entries.
57    pub line_no: Option<u64>,
58    /// Syntax/schema errors contain locations, never title or field values.
59    pub source: io::Error,
60}
61
62#[derive(Debug, Default)]
63pub struct TitleReadResult {
64    pub titles: HashMap<String, SessionTitle>,
65    /// Nonempty means partial results, even if all requested IDs were found.
66    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
97/// Read captured index prefixes with explicit limits and error policy. Collect
98/// mode preserves valid titles across bad rows/files; malformed Claude document
99/// syntax cannot be recovered within that file. Check errors before trusting
100/// completeness. No transcript bodies are read or used as fallback titles.
101pub 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    // Seal the length so concurrent appends cannot bypass the allocation budget.
256    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}