1use crate::{Agent, DiscoverError, DiscoverOperation, FileKind, Roots};
2use std::{
3 fs, io,
4 path::{Path, PathBuf},
5 time::SystemTime,
6};
7
8#[derive(Debug, Clone, Default)]
9pub struct DiscoverFilter {
10 pub agents: Vec<Agent>,
11 pub include_subagents: bool,
12 pub modified_after: Option<SystemTime>,
13}
14#[derive(Debug, Clone)]
15pub struct SessionFile {
16 pub agent: Agent,
17 pub path: PathBuf,
18 pub kind: FileKind,
19 pub size: u64,
20 pub modified: SystemTime,
21}
22#[derive(Debug, Default)]
23pub struct Discovery {
24 pub files: Vec<SessionFile>,
25 pub errors: Vec<DiscoverError>,
26}
27
28impl SessionFile {
29 pub fn inspect(agent: Agent, path: impl Into<PathBuf>) -> io::Result<Self> {
31 let path = path.into();
32 let meta = fs::symlink_metadata(&path)?;
33 if !meta.is_file() {
34 return Err(io::Error::new(
35 io::ErrorKind::InvalidInput,
36 "session path is not a regular file",
37 ));
38 }
39 let kind = if path.components().any(|p| p.as_os_str() == "subagents") {
40 FileKind::Subagent
41 } else {
42 FileKind::Main
43 };
44 Ok(Self {
45 agent,
46 path,
47 kind,
48 size: meta.len(),
49 modified: meta.modified()?,
50 })
51 }
52}
53
54fn scan_roots(roots: &Roots) -> Vec<(Agent, PathBuf)> {
55 let mut result = Vec::new();
56 if let Some(root) = &roots.claude {
57 result.push((Agent::ClaudeCode, root.join("projects")));
58 }
59 if let Some(root) = &roots.codex {
60 for subdir in ["sessions", "archived_sessions"] {
61 result.push((Agent::Codex, root.join(subdir)));
62 }
63 }
64 result
65}
66
67pub fn classify(path: &Path, roots: &Roots) -> io::Result<Option<SessionFile>> {
70 if path
71 .components()
72 .any(|c| matches!(c, std::path::Component::ParentDir))
73 {
74 return Err(io::Error::new(
75 io::ErrorKind::InvalidInput,
76 "parent path component",
77 ));
78 }
79 if path.extension().is_none_or(|e| e != "jsonl") {
80 return Ok(None);
81 }
82 for (agent, root) in scan_roots(roots) {
83 if path.starts_with(root) {
84 return SessionFile::inspect(agent, path).map(Some);
85 }
86 }
87 Ok(None)
88}
89
90pub fn discover(roots: &Roots, filter: &DiscoverFilter) -> Discovery {
91 let mut result = Discovery::default();
92 for (agent, root) in scan_roots(roots) {
93 scan_directory(agent, &root, filter, true, &mut result);
94 }
95 sort_files(&mut result);
96 result
97}
98
99pub fn discover_directory(agent: Agent, root: &Path, filter: &DiscoverFilter) -> Discovery {
104 let mut result = Discovery::default();
105 scan_directory(agent, root, filter, false, &mut result);
106 sort_files(&mut result);
107 result
108}
109
110fn scan_directory(
111 agent: Agent,
112 root: &Path,
113 filter: &DiscoverFilter,
114 optional: bool,
115 result: &mut Discovery,
116) {
117 if !filter.agents.is_empty() && !filter.agents.contains(&agent) {
118 return;
119 }
120 let skip_tagged_subagents = optional && !filter.include_subagents;
121 let mut pending = vec![(root.to_path_buf(), optional)];
122 while let Some((dir, optional)) = pending.pop() {
123 let metadata = match fs::symlink_metadata(&dir) {
124 Ok(m) => m,
125 Err(e) if optional && e.kind() == io::ErrorKind::NotFound => continue,
126 Err(source) => {
127 result.errors.push(DiscoverError {
128 agent,
129 path: dir,
130 source,
131 operation: DiscoverOperation::DirectoryMetadata,
132 });
133 continue;
134 }
135 };
136 if metadata.is_symlink() {
137 continue;
138 }
139 let entries = match fs::read_dir(&dir) {
140 Ok(entries) => entries,
141 Err(source) => {
142 result.errors.push(DiscoverError {
143 agent,
144 path: dir,
145 source,
146 operation: DiscoverOperation::ReadDirectory,
147 });
148 continue;
149 }
150 };
151 for entry in entries {
152 let entry = match entry {
153 Ok(e) => e,
154 Err(source) => {
155 result.errors.push(DiscoverError {
156 agent,
157 path: dir.clone(),
158 source,
159 operation: DiscoverOperation::ReadEntry,
160 });
161 continue;
162 }
163 };
164 let path = entry.path();
165 let ty = match entry.file_type() {
166 Ok(ty) => ty,
167 Err(source) => {
168 result.errors.push(DiscoverError {
169 agent,
170 path,
171 source,
172 operation: DiscoverOperation::FileType,
173 });
174 continue;
175 }
176 };
177 if ty.is_dir() {
178 if filter.include_subagents || entry.file_name() != "subagents" {
179 pending.push((path, false));
180 }
181 } else if ty.is_file() && is_session_path(agent, &path) {
182 match SessionFile::inspect(agent, &path) {
183 Ok(file)
184 if filter.modified_after.is_none_or(|t| file.modified >= t)
185 && (!skip_tagged_subagents || file.kind == FileKind::Main) =>
186 {
187 result.files.push(file)
188 }
189 Ok(_) => {}
190 Err(source) => result.errors.push(DiscoverError {
191 agent,
192 path,
193 source,
194 operation: DiscoverOperation::InspectFile,
195 }),
196 }
197 }
198 }
199 }
200}
201
202fn is_session_path(agent: Agent, path: &Path) -> bool {
203 let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("");
204 let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
205 match agent {
206 Agent::ClaudeCode | Agent::Codex | Agent::Pi | Agent::CodeBuddy | Agent::IFlow => {
207 ext == "jsonl"
208 }
209 Agent::QwenCode => {
210 ext == "jsonl"
211 && path
212 .parent()
213 .and_then(Path::file_name)
214 .is_some_and(|v| v == "chats")
215 }
216 Agent::GeminiCli => name.starts_with("session-") && matches!(ext, "json" | "jsonl"),
217 Agent::KimiCli => name == "wire.jsonl",
218 Agent::CopilotCli => name == "events.jsonl",
219 Agent::Cline | Agent::RooCode => name == "api_conversation_history.json",
220 Agent::OpenCode => matches!(ext, "json" | "db"),
221 Agent::Goose => matches!(ext, "json" | "db"),
222 Agent::Continue => ext == "json" && name != "sessions.json",
223 Agent::Cursor => name == "state.vscdb",
224 Agent::Grok => name == "updates.jsonl",
225 Agent::ClineCli => name.ends_with(".messages.json"),
226 Agent::Hermes => name == "state.db" || (name.starts_with("session_") && ext == "json"),
227 Agent::WorkBuddy => {
228 ext == "jsonl"
229 && !path
230 .components()
231 .any(|p| matches!(p.as_os_str().to_str(), Some("audit-log" | "logs")))
232 }
233 Agent::Qoder => ext == "jsonl" && !path.components().any(|p| p.as_os_str() == "logs"),
234 Agent::ZCode => name == "db.sqlite",
235 Agent::GrokBot => ext == "blob" && grokbot_transcript_name(path),
236 Agent::CursorCli => name == "store.db",
237 Agent::Zed => name == "threads.db" || ext == "json",
238 Agent::Warp => name == "warp.sqlite",
239 Agent::Antigravity => name.ends_with(".trajectory.json"),
240 }
241}
242fn sort_files(result: &mut Discovery) {
243 result.files.sort_by(|a, b| a.path.cmp(&b.path));
244 result
245 .files
246 .dedup_by(|a, b| a.path == b.path && a.agent == b.agent);
247}
248
249fn grokbot_transcript_name(path: &Path) -> bool {
252 let Some(stem) = path.file_stem().and_then(|v| v.to_str()) else {
253 return false;
254 };
255 let mut bits = 0_u32;
256 let mut count = 0_u32;
257 let mut bytes = Vec::new();
258 for byte in stem.bytes() {
259 let n = match byte.to_ascii_uppercase() {
260 b'A'..=b'Z' => byte.to_ascii_uppercase() - b'A',
261 b'2'..=b'7' => byte - b'2' + 26,
262 _ => return false,
263 };
264 bits = (bits << 5) | u32::from(n);
265 count += 5;
266 if count >= 8 {
267 count -= 8;
268 bytes.push((bits >> count) as u8);
269 bits &= (1 << count) - 1;
270 }
271 }
272 String::from_utf8(bytes).is_ok_and(|v| v.contains(".transcript.replicas."))
273}