Skip to main content

agent_sessions/
titles.rs

1//! Explicit index metadata; never falls back to prompt/response bodies.
2use crate::{Agent, Roots};
3use serde::{Deserialize, Serialize};
4use std::{
5    collections::{HashMap, HashSet},
6    fs::{self, File},
7    io::{self, BufRead, BufReader},
8    path::Path,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum SessionTitleOrigin {
14    SourceTitle,
15    SourceSummary,
16}
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct SessionTitle {
19    pub text: String,
20    pub origin: SessionTitleOrigin,
21}
22
23pub fn load_session_titles(
24    agent: Agent,
25    roots: &Roots,
26    ids: &[String],
27) -> io::Result<HashMap<String, SessionTitle>> {
28    if ids.is_empty() {
29        return Ok(HashMap::new());
30    }
31    let ids = ids.iter().map(String::as_str).collect::<HashSet<_>>();
32    match agent {
33        Agent::ClaudeCode => roots.claude.as_ref().map_or_else(
34            || Ok(HashMap::new()),
35            |root| claude(&root.join("projects"), &ids),
36        ),
37        Agent::Codex => roots.codex.as_ref().map_or_else(
38            || Ok(HashMap::new()),
39            |root| codex(&root.join("session_index.jsonl"), &ids),
40        ),
41    }
42}
43fn open(path: &Path) -> io::Result<Option<File>> {
44    match File::open(path) {
45        Ok(f) => Ok(Some(f)),
46        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
47        Err(e) => Err(e),
48    }
49}
50fn invalid(path: &Path, error: serde_json::Error) -> io::Error {
51    io::Error::new(
52        io::ErrorKind::InvalidData,
53        format!(
54            "Malformed title index {} at line {}, column {}",
55            path.display(),
56            error.line(),
57            error.column()
58        ),
59    )
60}
61#[derive(Deserialize)]
62struct CodexTitle {
63    id: String,
64    thread_name: String,
65}
66fn codex(path: &Path, ids: &HashSet<&str>) -> io::Result<HashMap<String, SessionTitle>> {
67    let mut out = HashMap::new();
68    let Some(file) = open(path)? else {
69        return Ok(out);
70    };
71    for (index, line) in BufReader::new(file).lines().enumerate() {
72        let line = line?;
73        if line.trim().is_empty() {
74            continue;
75        }
76        let row: CodexTitle = serde_json::from_str(&line).map_err(|_| {
77            io::Error::new(
78                io::ErrorKind::InvalidData,
79                format!(
80                    "Malformed title index {} at line {}",
81                    path.display(),
82                    index + 1
83                ),
84            )
85        })?;
86        if ids.contains(row.id.as_str()) && !row.thread_name.trim().is_empty() {
87            out.insert(
88                row.id,
89                SessionTitle {
90                    text: row.thread_name.trim().into(),
91                    origin: SessionTitleOrigin::SourceTitle,
92                },
93            );
94        }
95    }
96    Ok(out)
97}
98#[derive(Deserialize)]
99struct ClaudeIndex {
100    entries: Vec<ClaudeTitle>,
101}
102#[derive(Deserialize)]
103struct ClaudeTitle {
104    #[serde(rename = "sessionId")]
105    session_id: String,
106    summary: Option<String>,
107}
108fn claude(projects: &Path, ids: &HashSet<&str>) -> io::Result<HashMap<String, SessionTitle>> {
109    let mut out = HashMap::new();
110    let dirs = match fs::read_dir(projects) {
111        Ok(d) => d,
112        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
113        Err(e) => return Err(e),
114    };
115    for dir in dirs {
116        let dir = dir?;
117        if !dir.file_type()?.is_dir() {
118            continue;
119        }
120        let path = dir.path().join("sessions-index.json");
121        let Some(file) = open(&path)? else {
122            continue;
123        };
124        let index: ClaudeIndex =
125            serde_json::from_reader(BufReader::new(file)).map_err(|e| invalid(&path, e))?;
126        for row in index.entries {
127            if ids.contains(row.session_id.as_str())
128                && let Some(s) = row.summary.filter(|s| !s.trim().is_empty())
129            {
130                out.insert(
131                    row.session_id,
132                    SessionTitle {
133                        text: s.trim().into(),
134                        origin: SessionTitleOrigin::SourceSummary,
135                    },
136                );
137            }
138        }
139    }
140    Ok(out)
141}