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