1use super::*;
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6#[derive(Debug, Clone)]
8enum CachedNativeMetadata {
9 Claude(Option<(String, PathBuf, String)>),
11 Codex(Option<CodexSessionMetadata>),
13 IndexEligible(bool),
14}
15
16#[derive(Debug)]
17struct CachedNativeEntry {
18 modified_at: SystemTime,
19 size_bytes: u64,
20 metadata: CachedNativeMetadata,
21}
22
23#[derive(Debug, Default)]
24struct NativeScanCacheInner {
25 entries: HashMap<PathBuf, CachedNativeEntry>,
26 parsed_files: u64,
27}
28
29#[derive(Debug, Clone, Default)]
34pub struct NativeScanCache {
35 inner: Arc<Mutex<NativeScanCacheInner>>,
36}
37
38impl NativeScanCache {
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 pub(crate) fn shared() -> Self {
45 static CACHE: std::sync::OnceLock<NativeScanCache> = std::sync::OnceLock::new();
46 CACHE.get_or_init(Self::new).clone()
47 }
48
49 pub fn parsed_files(&self) -> u64 {
51 self.lock().parsed_files
52 }
53
54 pub(crate) fn index_eligible(&self, kind: HarnessKind, path: &Path) -> Result<bool> {
57 if kind == HarnessKind::Claude
58 && path
59 .components()
60 .any(|component| component.as_os_str() == "subagents")
61 {
62 return Ok(false);
63 }
64 let metadata = fs::metadata(path)?;
65 let modified_at = metadata.modified()?;
66 let size_bytes = metadata.len();
67 if let Some(CachedNativeMetadata::IndexEligible(eligible)) =
68 self.cached(path, modified_at, size_bytes)
69 {
70 return Ok(eligible);
71 }
72 let eligible = match kind {
73 HarnessKind::Codex => !codex::codex_session_summary(path)?.subagent,
74 HarnessKind::Claude => !claude::claude_native_summary(path)?.subagent,
75 other => bail!("{other:?} has its own top-level source enumeration"),
76 };
77 self.store(
78 path,
79 modified_at,
80 size_bytes,
81 CachedNativeMetadata::IndexEligible(eligible),
82 );
83 Ok(eligible)
84 }
85
86 fn lock(&self) -> std::sync::MutexGuard<'_, NativeScanCacheInner> {
87 self.inner
88 .lock()
89 .unwrap_or_else(|poisoned| poisoned.into_inner())
90 }
91
92 fn cached(
93 &self,
94 path: &Path,
95 modified_at: SystemTime,
96 size_bytes: u64,
97 ) -> Option<CachedNativeMetadata> {
98 let inner = self.lock();
99 let entry = inner.entries.get(path)?;
100 (entry.modified_at == modified_at && entry.size_bytes == size_bytes)
101 .then(|| entry.metadata.clone())
102 }
103
104 fn store(
105 &self,
106 path: &Path,
107 modified_at: SystemTime,
108 size_bytes: u64,
109 metadata: CachedNativeMetadata,
110 ) {
111 let mut inner = self.lock();
112 inner.parsed_files = inner.parsed_files.saturating_add(1);
113 inner.entries.insert(
114 path.to_owned(),
115 CachedNativeEntry {
116 modified_at,
117 size_bytes,
118 metadata,
119 },
120 );
121 }
122
123 pub(super) fn claude_metadata(
126 &self,
127 path: &Path,
128 modified_at: SystemTime,
129 size_bytes: u64,
130 parse: impl FnOnce() -> Result<Option<(String, PathBuf, String)>>,
131 ) -> Result<Option<(String, PathBuf, String)>> {
132 if let Some(CachedNativeMetadata::Claude(metadata)) =
133 self.cached(path, modified_at, size_bytes)
134 {
135 return Ok(metadata);
136 }
137 let metadata = parse()?;
138 self.store(
139 path,
140 modified_at,
141 size_bytes,
142 CachedNativeMetadata::Claude(metadata.clone()),
143 );
144 Ok(metadata)
145 }
146
147 pub(super) fn codex_metadata(
149 &self,
150 path: &Path,
151 modified_at: SystemTime,
152 size_bytes: u64,
153 parse: impl FnOnce() -> Result<Option<CodexSessionMetadata>>,
154 ) -> Result<Option<CodexSessionMetadata>> {
155 if let Some(CachedNativeMetadata::Codex(metadata)) =
156 self.cached(path, modified_at, size_bytes)
157 {
158 return Ok(metadata);
159 }
160 let metadata = parse()?;
161 self.store(
162 path,
163 modified_at,
164 size_bytes,
165 CachedNativeMetadata::Codex(metadata.clone()),
166 );
167 Ok(metadata)
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct LocatedNativeSession {
175 pub native_session_id: String,
176 pub source_path: PathBuf,
177}
178
179#[derive(Debug, Clone)]
181pub struct NativeSessionListing {
182 pub native_session_id: String,
183 pub title: String,
184 pub modified_at: SystemTime,
185 pub git_branch: String,
186 pub size_bytes: u64,
187 pub cwd: PathBuf,
188 pub unavailable_reason: Option<&'static str>,
190 pub natively_archived: bool,
192}
193
194pub fn locate_native_session(
196 harness: HarnessKind,
197 home: &Path,
198 selection: &ClaudeSessionSelection,
199) -> Result<LocatedNativeSession> {
200 let (native_session_id, source_path) = match harness {
201 HarnessKind::Muse => {
202 return muse::locate(&mj_checkpoint::native::muse_sessions_root(home)?, selection);
203 }
204 HarnessKind::Codex => {
205 let located = locate_codex_session(home, selection)?;
206 (located.native_session_id, located.jsonl_path)
207 }
208 HarnessKind::Claude => {
209 let located = locate_claude_session(home, selection)?;
210 (located.native_session_id, located.jsonl_path)
211 }
212 HarnessKind::Kimi => {
213 let located = locate_kimi_session(home, selection)?;
214 (located.native_session_id, located.session_path)
215 }
216 HarnessKind::Grok => {
217 let located = locate_grok_session(home, selection)?;
218 (located.native_session_id, located.session_path)
219 }
220 HarnessKind::OpenCode => bail!(
221 "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot import them yet"
222 ),
223 };
224 Ok(LocatedNativeSession {
225 native_session_id,
226 source_path,
227 })
228}
229
230pub fn native_session_id_from_path(harness: HarnessKind, path: &Path) -> Option<String> {
241 match harness {
242 HarnessKind::Claude => path.file_stem()?.to_str().map(str::to_owned),
243 HarnessKind::Codex => codex_rollout_id_from_path(path).map(str::to_owned),
244 HarnessKind::Kimi | HarnessKind::Grok => path.file_name()?.to_str().map(str::to_owned),
245 HarnessKind::Muse => path.parent()?.file_name()?.to_str().map(str::to_owned),
246 HarnessKind::OpenCode => None,
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct NativeSessionSource {
258 pub native_session_id: String,
259 pub source_path: PathBuf,
260 pub modified_at: SystemTime,
263}
264
265pub fn list_native_session_sources(
271 harness: HarnessKind,
272 home: &Path,
273) -> Result<Vec<NativeSessionSource>> {
274 let sources = match harness {
275 HarnessKind::Kimi => kimi_indexed_candidates(home, &home.join("sessions"))?
276 .into_iter()
277 .map(|candidate| NativeSessionSource {
278 native_session_id: candidate.native_session_id,
279 source_path: candidate.session_path,
280 modified_at: candidate.modified_at,
281 })
282 .collect(),
283 HarnessKind::Grok => grok::grok_candidates(&home.join("sessions"))?
284 .into_iter()
285 .map(|candidate| NativeSessionSource {
286 native_session_id: candidate.native_session_id,
287 source_path: candidate.session_path,
288 modified_at: candidate.modified_at,
289 })
290 .collect(),
291 HarnessKind::Muse => muse::list_sources(&mj_checkpoint::native::muse_sessions_root(home)?)?,
292 HarnessKind::OpenCode => bail!(
293 "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot enumerate them yet"
294 ),
295 other => bail!("{other:?} keeps one session per file; there is nothing to enumerate"),
296 };
297 Ok(sources)
298}
299
300pub fn native_session_title(harness: HarnessKind, source_path: &Path) -> Option<String> {
304 match harness {
305 HarnessKind::Kimi => {
306 kimi_state_listing_metadata(source_path, Path::new("")).map(|(title, _, _)| title)
307 }
308 HarnessKind::Grok => grok::grok_listing_metadata(source_path).0,
309 _ => None,
310 }
311}
312
313pub fn read_native_transcript(
315 harness: HarnessKind,
316 source_path: &Path,
317) -> Result<ClaudeTranscript> {
318 match harness {
319 HarnessKind::Muse => muse::read_transcript(source_path),
320 HarnessKind::Codex => read_codex_transcript(source_path),
321 HarnessKind::Claude => read_claude_transcript(source_path),
322 HarnessKind::Kimi => read_kimi_transcript(source_path),
323 HarnessKind::Grok => read_grok_transcript(source_path),
324 HarnessKind::OpenCode => bail!(
325 "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot import them yet"
326 ),
327 }
328}
329
330pub fn scan_native_sessions(
332 harness: HarnessKind,
333 home: &Path,
334 cache: &NativeScanCache,
335 mut report: impl FnMut(SessionScanProgress<NativeSessionListing>),
336) -> Result<()> {
337 let mut forward = |scanned, total, session| {
338 report(SessionScanProgress {
339 scanned,
340 total,
341 session,
342 });
343 };
344 match harness {
345 HarnessKind::Muse => muse::scan(
346 &mj_checkpoint::native::muse_sessions_root(home)?,
347 |progress| {
348 forward(progress.scanned, progress.total, progress.session);
349 },
350 ),
351 HarnessKind::Codex => scan_codex_sessions(home, cache, |progress| {
352 let session = progress.session.map(|session| NativeSessionListing {
353 unavailable_reason: session.history_mode.import_issue(),
354 native_session_id: session.native_session_id,
355 title: session.title,
356 modified_at: session.modified_at,
357 git_branch: session.git_branch,
358 size_bytes: session.size_bytes,
359 cwd: session.cwd,
360 natively_archived: session.natively_archived,
361 });
362 forward(progress.scanned, progress.total, session);
363 }),
364 HarnessKind::Claude => scan_claude_sessions(home, cache, |progress| {
365 let session = progress.session.map(|session| NativeSessionListing {
366 native_session_id: session.native_session_id,
367 title: session.title,
368 modified_at: session.modified_at,
369 git_branch: session.git_branch,
370 size_bytes: session.size_bytes,
371 cwd: session.cwd,
372 unavailable_reason: None,
373 natively_archived: false,
374 });
375 forward(progress.scanned, progress.total, session);
376 }),
377 HarnessKind::Kimi => scan_kimi_sessions(home, |progress| {
378 let session = progress.session.map(|session| NativeSessionListing {
379 native_session_id: session.native_session_id,
380 title: session.title,
381 modified_at: session.modified_at,
382 git_branch: session.git_branch,
383 size_bytes: session.size_bytes,
384 cwd: session.cwd,
385 unavailable_reason: None,
386 natively_archived: false,
387 });
388 forward(progress.scanned, progress.total, session);
389 }),
390 HarnessKind::Grok => scan_grok_sessions(home, |progress| {
391 let session = progress.session.map(|session| NativeSessionListing {
392 native_session_id: session.native_session_id,
393 title: session.title,
394 modified_at: session.modified_at,
395 git_branch: session.git_branch,
396 size_bytes: session.size_bytes,
397 cwd: session.cwd,
398 unavailable_reason: None,
399 natively_archived: false,
400 });
401 forward(progress.scanned, progress.total, session);
402 }),
403 HarnessKind::OpenCode => bail!(
404 "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot enumerate them yet"
405 ),
406 }
407}