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            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    // Seal the length so concurrent appends cannot bypass the allocation budget.
262    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}