Skip to main content

mj_controller/import/
native.rs

1use super::*;
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6/// What one scanned native session file parsed into, keyed by the file itself.
7#[derive(Debug, Clone)]
8enum CachedNativeMetadata {
9    /// `claude_native_metadata`, including its "filtered out" verdict.
10    Claude(Option<(String, PathBuf, String)>),
11    /// `codex_session_metadata`, including its "not interactive" verdict.
12    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/// Remembers what each native session file parsed into, keyed by its path,
30/// modified time and size. The parsers are pure functions of a file's content,
31/// so an unchanged file never has to be opened again. One cache lives for the
32/// process, so reopening the resume dialog reparses only what changed.
33#[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    /// How many files this cache has actually parsed, for tests and diagnostics.
44    pub fn parsed_files(&self) -> u64 {
45        self.lock().parsed_files
46    }
47
48    /// Index discovery excludes identified children, preserving parents even
49    /// when their entrypoint or missing working directory prevents importing.
50    pub(crate) fn index_eligible(&self, kind: HarnessKind, path: &Path) -> Result<bool> {
51        if kind == HarnessKind::Claude
52            && path
53                .components()
54                .any(|component| component.as_os_str() == "subagents")
55        {
56            return Ok(false);
57        }
58        let metadata = fs::metadata(path)?;
59        let modified_at = metadata.modified()?;
60        let size_bytes = metadata.len();
61        if let Some(CachedNativeMetadata::IndexEligible(eligible)) =
62            self.cached(path, modified_at, size_bytes)
63        {
64            return Ok(eligible);
65        }
66        let eligible = match kind {
67            HarnessKind::Codex => !codex::codex_session_summary(path)?.subagent,
68            HarnessKind::Claude => !claude::claude_native_summary(path)?.subagent,
69            other => bail!("{other:?} has its own top-level source enumeration"),
70        };
71        self.store(
72            path,
73            modified_at,
74            size_bytes,
75            CachedNativeMetadata::IndexEligible(eligible),
76        );
77        Ok(eligible)
78    }
79
80    fn lock(&self) -> std::sync::MutexGuard<'_, NativeScanCacheInner> {
81        self.inner
82            .lock()
83            .unwrap_or_else(|poisoned| poisoned.into_inner())
84    }
85
86    fn cached(
87        &self,
88        path: &Path,
89        modified_at: SystemTime,
90        size_bytes: u64,
91    ) -> Option<CachedNativeMetadata> {
92        let inner = self.lock();
93        let entry = inner.entries.get(path)?;
94        (entry.modified_at == modified_at && entry.size_bytes == size_bytes)
95            .then(|| entry.metadata.clone())
96    }
97
98    fn store(
99        &self,
100        path: &Path,
101        modified_at: SystemTime,
102        size_bytes: u64,
103        metadata: CachedNativeMetadata,
104    ) {
105        let mut inner = self.lock();
106        inner.parsed_files = inner.parsed_files.saturating_add(1);
107        inner.entries.insert(
108            path.to_owned(),
109            CachedNativeEntry {
110                modified_at,
111                size_bytes,
112                metadata,
113            },
114        );
115    }
116
117    /// Claude metadata for one transcript, parsing only on a cache miss.
118    /// Errors are returned to the caller and never cached.
119    pub(super) fn claude_metadata(
120        &self,
121        path: &Path,
122        modified_at: SystemTime,
123        size_bytes: u64,
124        parse: impl FnOnce() -> Result<Option<(String, PathBuf, String)>>,
125    ) -> Result<Option<(String, PathBuf, String)>> {
126        if let Some(CachedNativeMetadata::Claude(metadata)) =
127            self.cached(path, modified_at, size_bytes)
128        {
129            return Ok(metadata);
130        }
131        let metadata = parse()?;
132        self.store(
133            path,
134            modified_at,
135            size_bytes,
136            CachedNativeMetadata::Claude(metadata.clone()),
137        );
138        Ok(metadata)
139    }
140
141    /// Codex metadata for one rollout, parsing only on a cache miss.
142    pub(super) fn codex_metadata(
143        &self,
144        path: &Path,
145        modified_at: SystemTime,
146        size_bytes: u64,
147        parse: impl FnOnce() -> Result<Option<CodexSessionMetadata>>,
148    ) -> Result<Option<CodexSessionMetadata>> {
149        if let Some(CachedNativeMetadata::Codex(metadata)) =
150            self.cached(path, modified_at, size_bytes)
151        {
152            return Ok(metadata);
153        }
154        let metadata = parse()?;
155        self.store(
156            path,
157            modified_at,
158            size_bytes,
159            CachedNativeMetadata::Codex(metadata.clone()),
160        );
161        Ok(metadata)
162    }
163}
164
165/// One native session located on disk, normalized across harnesses: the id
166/// `session/load` takes and the file or directory its transcript is read from.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct LocatedNativeSession {
169    pub native_session_id: String,
170    pub source_path: PathBuf,
171}
172
173/// One native session as a picker lists it, normalized across harnesses.
174#[derive(Debug, Clone)]
175pub struct NativeSessionListing {
176    pub native_session_id: String,
177    pub title: String,
178    pub modified_at: SystemTime,
179    pub git_branch: String,
180    pub size_bytes: u64,
181    pub cwd: PathBuf,
182    /// Why this session cannot be imported, when it cannot be.
183    pub unavailable_reason: Option<&'static str>,
184    /// Archived inside the harness itself. Only Codex reports this today.
185    pub natively_archived: bool,
186}
187
188/// Locate one native session for any harness.
189pub fn locate_native_session(
190    harness: HarnessKind,
191    home: &Path,
192    selection: &ClaudeSessionSelection,
193) -> Result<LocatedNativeSession> {
194    let (native_session_id, source_path) = match harness {
195        HarnessKind::Muse => {
196            return muse::locate(&mj_checkpoint::native::muse_sessions_root(home)?, selection);
197        }
198        HarnessKind::Codex => {
199            let located = locate_codex_session(home, selection)?;
200            (located.native_session_id, located.jsonl_path)
201        }
202        HarnessKind::Claude => {
203            let located = locate_claude_session(home, selection)?;
204            (located.native_session_id, located.jsonl_path)
205        }
206        HarnessKind::Kimi => {
207            let located = locate_kimi_session(home, selection)?;
208            (located.native_session_id, located.session_path)
209        }
210        HarnessKind::Grok => {
211            let located = locate_grok_session(home, selection)?;
212            (located.native_session_id, located.session_path)
213        }
214    };
215    Ok(LocatedNativeSession {
216        native_session_id,
217        source_path,
218    })
219}
220
221/// The id [`locate_native_session`] takes, read back from the path that
222/// locator produced. `None` when the path has no readable name.
223///
224/// The inverse of the locators above, so every caller that has only a
225/// transcript path — the SessionWiki index stores one per row — names the
226/// session the same way `mj import` does. Each harness keeps its id in a
227/// different part of the path: Claude Code names the file after the session,
228/// Codex appends the thread UUID to a `rollout-` prefix, Kimi and Grok name
229/// the session's own directory, and Muse names the directory above
230/// `session.jsonl`.
231pub fn native_session_id_from_path(harness: HarnessKind, path: &Path) -> Option<String> {
232    match harness {
233        HarnessKind::Claude => path.file_stem()?.to_str().map(str::to_owned),
234        HarnessKind::Codex => codex_rollout_id_from_path(path).map(str::to_owned),
235        HarnessKind::Kimi | HarnessKind::Grok => path.file_name()?.to_str().map(str::to_owned),
236        HarnessKind::Muse => path.parent()?.file_name()?.to_str().map(str::to_owned),
237    }
238}
239
240/// Where one native session's transcript lives and when it last changed.
241///
242/// Cheaper than [`NativeSessionListing`]: no git branch and no directory size,
243/// because the search index only needs a stable key and a change token.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct NativeSessionSource {
246    pub native_session_id: String,
247    pub source_path: PathBuf,
248    /// The newest modification time of the session's own transcript files, not
249    /// of the directory that holds them.
250    pub modified_at: SystemTime,
251}
252
253/// List the sessions of one harness home for indexing.
254///
255/// Only the harnesses whose sessions Mjolnir itself has to enumerate are
256/// supported; Codex and Claude Code keep one file per session, which their
257/// own readers walk directly.
258pub fn list_native_session_sources(
259    harness: HarnessKind,
260    home: &Path,
261) -> Result<Vec<NativeSessionSource>> {
262    let sources = match harness {
263        HarnessKind::Kimi => kimi_indexed_candidates(home, &home.join("sessions"))?
264            .into_iter()
265            .map(|candidate| NativeSessionSource {
266                native_session_id: candidate.native_session_id,
267                source_path: candidate.session_path,
268                modified_at: candidate.modified_at,
269            })
270            .collect(),
271        HarnessKind::Grok => grok::grok_candidates(&home.join("sessions"))?
272            .into_iter()
273            .map(|candidate| NativeSessionSource {
274                native_session_id: candidate.native_session_id,
275                source_path: candidate.session_path,
276                modified_at: candidate.modified_at,
277            })
278            .collect(),
279        HarnessKind::Muse => muse::list_sources(&mj_checkpoint::native::muse_sessions_root(home)?)?,
280        other => bail!("{other:?} keeps one session per file; there is nothing to enumerate"),
281    };
282    Ok(sources)
283}
284
285/// The title the harness itself records for one session, read from that
286/// session's own metadata file. `None` when the harness records none, which
287/// leaves the caller to derive a title from the conversation.
288pub fn native_session_title(harness: HarnessKind, source_path: &Path) -> Option<String> {
289    match harness {
290        HarnessKind::Kimi => {
291            kimi_state_listing_metadata(source_path, Path::new("")).map(|(title, _, _)| title)
292        }
293        HarnessKind::Grok => grok::grok_listing_metadata(source_path).0,
294        _ => None,
295    }
296}
297
298/// Project one native session into the canonical transcript, for any harness.
299pub fn read_native_transcript(
300    harness: HarnessKind,
301    source_path: &Path,
302) -> Result<ClaudeTranscript> {
303    match harness {
304        HarnessKind::Muse => muse::read_transcript(source_path),
305        HarnessKind::Codex => read_codex_transcript(source_path),
306        HarnessKind::Claude => read_claude_transcript(source_path),
307        HarnessKind::Kimi => read_kimi_transcript(source_path),
308        HarnessKind::Grok => read_grok_transcript(source_path),
309    }
310}
311
312/// Scan a harness home newest first, reporting after every candidate.
313pub fn scan_native_sessions(
314    harness: HarnessKind,
315    home: &Path,
316    cache: &NativeScanCache,
317    mut report: impl FnMut(SessionScanProgress<NativeSessionListing>),
318) -> Result<()> {
319    let mut forward = |scanned, total, session| {
320        report(SessionScanProgress {
321            scanned,
322            total,
323            session,
324        });
325    };
326    match harness {
327        HarnessKind::Muse => muse::scan(
328            &mj_checkpoint::native::muse_sessions_root(home)?,
329            |progress| {
330                forward(progress.scanned, progress.total, progress.session);
331            },
332        ),
333        HarnessKind::Codex => scan_codex_sessions(home, cache, |progress| {
334            let session = progress.session.map(|session| NativeSessionListing {
335                unavailable_reason: session.history_mode.import_issue(),
336                native_session_id: session.native_session_id,
337                title: session.title,
338                modified_at: session.modified_at,
339                git_branch: session.git_branch,
340                size_bytes: session.size_bytes,
341                cwd: session.cwd,
342                natively_archived: session.natively_archived,
343            });
344            forward(progress.scanned, progress.total, session);
345        }),
346        HarnessKind::Claude => scan_claude_sessions(home, cache, |progress| {
347            let session = progress.session.map(|session| NativeSessionListing {
348                native_session_id: session.native_session_id,
349                title: session.title,
350                modified_at: session.modified_at,
351                git_branch: session.git_branch,
352                size_bytes: session.size_bytes,
353                cwd: session.cwd,
354                unavailable_reason: None,
355                natively_archived: false,
356            });
357            forward(progress.scanned, progress.total, session);
358        }),
359        HarnessKind::Kimi => scan_kimi_sessions(home, |progress| {
360            let session = progress.session.map(|session| NativeSessionListing {
361                native_session_id: session.native_session_id,
362                title: session.title,
363                modified_at: session.modified_at,
364                git_branch: session.git_branch,
365                size_bytes: session.size_bytes,
366                cwd: session.cwd,
367                unavailable_reason: None,
368                natively_archived: false,
369            });
370            forward(progress.scanned, progress.total, session);
371        }),
372        HarnessKind::Grok => scan_grok_sessions(home, |progress| {
373            let session = progress.session.map(|session| NativeSessionListing {
374                native_session_id: session.native_session_id,
375                title: session.title,
376                modified_at: session.modified_at,
377                git_branch: session.git_branch,
378                size_bytes: session.size_bytes,
379                cwd: session.cwd,
380                unavailable_reason: None,
381                natively_archived: false,
382            });
383            forward(progress.scanned, progress.total, session);
384        }),
385    }
386}