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    /// The cache shared by index discovery and Mjolnir's SessionWiki reads.
44    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    /// How many files this cache has actually parsed, for tests and diagnostics.
50    pub fn parsed_files(&self) -> u64 {
51        self.lock().parsed_files
52    }
53
54    /// Index discovery excludes identified children, preserving parents even
55    /// when their entrypoint or missing working directory prevents importing.
56    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    /// Claude metadata for one transcript, parsing only on a cache miss.
124    /// Errors are returned to the caller and never cached.
125    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    /// Codex metadata for one rollout, parsing only on a cache miss.
148    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/// One native session located on disk, normalized across harnesses: the id
172/// `session/load` takes and the file or directory its transcript is read from.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct LocatedNativeSession {
175    pub native_session_id: String,
176    pub source_path: PathBuf,
177}
178
179/// One native session as a picker lists it, normalized across harnesses.
180#[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    /// Why this session cannot be imported, when it cannot be.
189    pub unavailable_reason: Option<&'static str>,
190    /// Archived inside the harness itself. Only Codex reports this today.
191    pub natively_archived: bool,
192}
193
194/// Locate one native session for any harness.
195pub 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
230/// The id [`locate_native_session`] takes, read back from the path that
231/// locator produced. `None` when the path has no readable name.
232///
233/// The inverse of the locators above, so every caller that has only a
234/// transcript path — the SessionWiki index stores one per row — names the
235/// session the same way `mj import` does. Each harness keeps its id in a
236/// different part of the path: Claude Code names the file after the session,
237/// Codex appends the thread UUID to a `rollout-` prefix, Kimi and Grok name
238/// the session's own directory, and Muse names the directory above
239/// `session.jsonl`.
240pub 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        // OpenCode sessions live in SQLite rows, not files, so no transcript
247        // path carries their id.
248        HarnessKind::OpenCode => None,
249    }
250}
251
252/// Where one native session's transcript lives and when it last changed.
253///
254/// Cheaper than [`NativeSessionListing`]: no git branch and no directory size,
255/// because the search index only needs a stable key and a change token.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct NativeSessionSource {
258    pub native_session_id: String,
259    pub source_path: PathBuf,
260    /// The newest modification time of the session's own transcript files, not
261    /// of the directory that holds them.
262    pub modified_at: SystemTime,
263}
264
265/// List the sessions of one harness home for indexing.
266///
267/// Only the harnesses whose sessions Mjolnir itself has to enumerate are
268/// supported; Codex and Claude Code keep one file per session, which their
269/// own readers walk directly.
270pub 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
300/// The title the harness itself records for one session, read from that
301/// session's own metadata file. `None` when the harness records none, which
302/// leaves the caller to derive a title from the conversation.
303pub 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
313/// Project one native session into the canonical transcript, for any harness.
314pub 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
330/// Scan a harness home newest first, reporting after every candidate.
331pub 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}