Skip to main content

ai_memory/recover/
transcript_paths.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Host transcript path resolver. Each MCP-aware host (Claude Code,
5//! Codex CLI, Gemini CLI, plus IDE-plugin / SDK-shim surfaces in
6//! v0.8 — see ROADMAP §11.4.H) writes per-turn JSONL or equivalent
7//! transcript artifacts to a known location. This module owns the
8//! table of known locations and the resolver that picks the
9//! most-recently-modified candidate for a given host.
10
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15/// Host classification driving which path-resolver arm to use.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum HostKind {
19    /// Walk every supported host's candidate set and pick the
20    /// transcript with the most recent mtime. Default for the
21    /// CLI subcommand + MCP tool.
22    #[default]
23    Auto,
24    /// Anthropic Claude Code — JSONL per turn under
25    /// `~/.claude/projects/-<cwd-encoded>/*.jsonl`.
26    ClaudeCode,
27    /// OpenAI Codex CLI — transcript layout subject to per-version
28    /// drift; the resolver attempts the documented locations and
29    /// surfaces a `not-found` error path under `auto`.
30    Codex,
31    /// Google Gemini CLI — same shape as Codex; layout to be
32    /// confirmed per the v0.7.0 #1389 implementation slice.
33    Gemini,
34}
35
36impl HostKind {
37    /// Stable string tag used in `recovered-from-transcript` memory
38    /// tags + in the `host:<kind>` JSON serialization arm.
39    #[must_use]
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Auto => "auto",
43            Self::ClaudeCode => "claude-code",
44            Self::Codex => "codex",
45            Self::Gemini => "gemini",
46        }
47    }
48}
49
50/// Resolve the most-recently-modified transcript file for the given
51/// host + cwd. When `host == HostKind::Auto`, walks every supported
52/// host's candidate set and returns the global most-recent.
53///
54/// Returns `Ok(None)` (not an `Err`) when no transcript is located
55/// for any supported host — this is a legitimate steady-state on a
56/// fresh dev box where no AI agent has ever written a transcript.
57///
58/// # Errors
59///
60/// Currently never errors at the resolver level; the underlying
61/// filesystem walk surfaces I/O issues via empty-candidate fallthrough.
62/// The signature reserves the error arm for future host adapters
63/// that perform stricter validation.
64pub fn resolve_transcript(host: HostKind, cwd: &Path) -> Result<Option<PathBuf>, ResolveError> {
65    let candidates: Vec<PathBuf> = match host {
66        HostKind::Auto => {
67            let mut all = Vec::new();
68            all.extend(claude_code_candidates(cwd));
69            all.extend(codex_candidates(cwd));
70            all.extend(gemini_candidates(cwd));
71            all
72        }
73        HostKind::ClaudeCode => claude_code_candidates(cwd),
74        HostKind::Codex => codex_candidates(cwd),
75        HostKind::Gemini => gemini_candidates(cwd),
76    };
77    Ok(most_recently_modified(&candidates))
78}
79
80/// Claude Code transcripts live under
81/// `$HOME/.claude/projects/-<cwd-encoded>/*.jsonl`. The cwd
82/// encoding replaces `/` with `-` and prefixes a leading `-`.
83fn claude_code_candidates(cwd: &Path) -> Vec<PathBuf> {
84    let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
85        return Vec::new();
86    };
87    let cwd_str = cwd.to_string_lossy();
88    let encoded = format!("-{}", cwd_str.replace('/', "-"));
89    let project_dir = home.join(".claude").join("projects").join(&encoded);
90    list_jsonl_in(&project_dir)
91}
92
93/// Codex CLI candidate set. The exact location is host-version
94/// dependent; this stub returns the documented v0.7.0 candidate
95/// set. A full per-version sweep lands as a v0.7.0 implementation
96/// slice (#1389 acceptance criterion §C).
97fn codex_candidates(_cwd: &Path) -> Vec<PathBuf> {
98    let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
99        return Vec::new();
100    };
101    let sessions = home.join(".codex").join("sessions");
102    list_jsonl_in(&sessions)
103}
104
105/// Gemini CLI candidate set. Same as Codex — to be confirmed by
106/// the implementation slice. Stub returns the most-likely path.
107fn gemini_candidates(_cwd: &Path) -> Vec<PathBuf> {
108    let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
109        return Vec::new();
110    };
111    let sessions = home.join(".config").join("gemini").join("sessions");
112    list_jsonl_in(&sessions)
113}
114
115/// List every `*.jsonl` (or `*.json`) file in a directory, swallowing
116/// I/O errors (a non-existent directory is a legitimate empty-candidate
117/// state, not an error).
118fn list_jsonl_in(dir: &Path) -> Vec<PathBuf> {
119    let Ok(entries) = std::fs::read_dir(dir) else {
120        return Vec::new();
121    };
122    entries
123        .filter_map(Result::ok)
124        .map(|e| e.path())
125        .filter(|p| {
126            p.extension()
127                .and_then(|e| e.to_str())
128                .is_some_and(|ext| ext == "jsonl" || ext == "json")
129        })
130        .collect()
131}
132
133/// Pick the most-recently-modified path from a candidate list.
134/// Returns `None` when the list is empty or every candidate's
135/// metadata read failed.
136fn most_recently_modified(candidates: &[PathBuf]) -> Option<PathBuf> {
137    candidates
138        .iter()
139        .filter_map(|p| {
140            let mtime = std::fs::metadata(p).ok()?.modified().ok()?;
141            Some((p.clone(), mtime))
142        })
143        .max_by_key(|(_, t)| *t)
144        .map(|(p, _)| p)
145}
146
147/// Errors surfaced by [`resolve_transcript`]. Reserved for future
148/// host adapters that perform validation beyond the current
149/// "filesystem walk + mtime pick" shape.
150#[derive(Debug)]
151pub enum ResolveError {
152    /// No `HOME` directory available — the resolver cannot locate
153    /// any of the supported host layouts without it.
154    NoHome,
155}
156
157impl std::fmt::Display for ResolveError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            Self::NoHome => write!(f, "resolve: no $HOME set"),
161        }
162    }
163}
164
165impl std::error::Error for ResolveError {}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn host_kind_as_str_round_trip() {
173        assert_eq!(HostKind::Auto.as_str(), "auto");
174        assert_eq!(HostKind::ClaudeCode.as_str(), "claude-code");
175        assert_eq!(HostKind::Codex.as_str(), "codex");
176        assert_eq!(HostKind::Gemini.as_str(), "gemini");
177    }
178
179    #[test]
180    fn resolve_with_no_candidates_returns_none() {
181        // Use a path that doesn't exist; the resolver should return
182        // Ok(None) rather than error out.
183        let tmp = std::env::temp_dir().join("non-existent-cwd-for-tests");
184        let res = resolve_transcript(HostKind::ClaudeCode, &tmp);
185        assert!(res.is_ok());
186        assert!(res.unwrap().is_none());
187    }
188
189    #[test]
190    fn host_kind_serde_uses_kebab_case() {
191        let serialized = serde_json::to_string(&HostKind::ClaudeCode).unwrap();
192        assert_eq!(serialized, "\"claude-code\"");
193        let parsed: HostKind = serde_json::from_str("\"codex\"").unwrap();
194        assert_eq!(parsed, HostKind::Codex);
195    }
196
197    /// In-tree scratch root honoring the project no-`/tmp` HARD RULE.
198    fn local_runs_dir() -> std::path::PathBuf {
199        let root = std::env::current_dir()
200            .unwrap_or_else(|_| PathBuf::from("."))
201            .join(".local-runs")
202            .join("transcript-paths-unit-test");
203        std::fs::create_dir_all(&root).ok();
204        root
205    }
206
207    /// Serialize HOME mutations across the env-touching tests in this
208    /// module so they cannot clobber each other under `--test-threads>1`.
209    fn home_lock() -> std::sync::MutexGuard<'static, ()> {
210        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
211        LOCK.get_or_init(|| std::sync::Mutex::new(()))
212            .lock()
213            .unwrap_or_else(|e| e.into_inner())
214    }
215
216    /// RAII guard that points `$HOME` at a temp dir and restores the
217    /// prior value (if any) on drop.
218    struct HomeGuard {
219        prev: Option<std::ffi::OsString>,
220    }
221    impl HomeGuard {
222        fn set(dir: &Path) -> Self {
223            let prev = std::env::var_os("HOME");
224            // SAFETY: tests serialize on `home_lock()` before constructing.
225            unsafe {
226                std::env::set_var("HOME", dir);
227            }
228            Self { prev }
229        }
230    }
231    impl Drop for HomeGuard {
232        fn drop(&mut self) {
233            unsafe {
234                match &self.prev {
235                    Some(v) => std::env::set_var("HOME", v),
236                    None => std::env::remove_var("HOME"),
237                }
238            }
239        }
240    }
241
242    #[test]
243    fn resolve_error_display_and_trait() {
244        let e = ResolveError::NoHome;
245        assert_eq!(e.to_string(), "resolve: no $HOME set");
246        // Debug + std::error::Error trait wiring.
247        let _: &dyn std::error::Error = &e;
248        assert!(format!("{e:?}").contains("NoHome"));
249    }
250
251    #[test]
252    fn claude_code_resolver_finds_most_recent_jsonl() {
253        use std::io::Write;
254        let _g = home_lock();
255        let tmp = tempfile::tempdir_in(local_runs_dir()).unwrap();
256        let home = tmp.path();
257        let _home = HomeGuard::set(home);
258
259        // Build the encoded project dir for a synthetic cwd.
260        let cwd = std::path::Path::new("/work/proj");
261        let encoded = format!("-{}", cwd.to_string_lossy().replace('/', "-"));
262        let proj = home.join(".claude").join("projects").join(&encoded);
263        std::fs::create_dir_all(&proj).unwrap();
264
265        // Two jsonl files + one non-jsonl that must be ignored. Write
266        // `older` first, then sleep past the filesystem mtime resolution
267        // before writing `newer`, so `most_recently_modified` picks
268        // `newer` without depending on a clock-setting crate.
269        let older = proj.join("a.jsonl");
270        let newer = proj.join("b.jsonl");
271        std::fs::write(proj.join("ignore.txt"), b"nope").unwrap();
272        {
273            let mut f = std::fs::File::create(&older).unwrap();
274            writeln!(f, "{{}}").unwrap();
275        }
276        std::thread::sleep(std::time::Duration::from_millis(20));
277        {
278            let mut f = std::fs::File::create(&newer).unwrap();
279            writeln!(f, "{{}}").unwrap();
280        }
281
282        // The resolver returns one of the two candidates; whichever the
283        // filesystem reports as most-recent. On filesystems with coarse
284        // mtime granularity the two could tie, so accept either — the
285        // load-bearing claim is that it resolves to a real jsonl in the
286        // project dir and never the .txt.
287        let got = resolve_transcript(HostKind::ClaudeCode, cwd)
288            .unwrap()
289            .unwrap();
290        assert!(
291            got == older || got == newer,
292            "resolved to an unexpected path: {}",
293            got.display()
294        );
295        assert_eq!(got.extension().and_then(|e| e.to_str()), Some("jsonl"));
296
297        // Auto walks every host's candidate set; here only Claude Code
298        // has files, so a Claude Code jsonl wins.
299        let got_auto = resolve_transcript(HostKind::Auto, cwd).unwrap().unwrap();
300        assert_eq!(got_auto.extension().and_then(|e| e.to_str()), Some("jsonl"));
301    }
302
303    #[test]
304    fn codex_and_gemini_resolvers_walk_their_dirs() {
305        use std::io::Write;
306        let _g = home_lock();
307        let tmp = tempfile::tempdir_in(local_runs_dir()).unwrap();
308        let home = tmp.path();
309        let _home = HomeGuard::set(home);
310        let cwd = std::path::Path::new("/irrelevant");
311
312        // Codex sessions dir.
313        let codex = home.join(".codex").join("sessions");
314        std::fs::create_dir_all(&codex).unwrap();
315        let cfile = codex.join("s.json");
316        {
317            let mut f = std::fs::File::create(&cfile).unwrap();
318            writeln!(f, "{{}}").unwrap();
319        }
320        assert_eq!(
321            resolve_transcript(HostKind::Codex, cwd).unwrap().as_deref(),
322            Some(cfile.as_path())
323        );
324
325        // Gemini sessions dir.
326        let gemini = home.join(".config").join("gemini").join("sessions");
327        std::fs::create_dir_all(&gemini).unwrap();
328        let gfile = gemini.join("g.jsonl");
329        {
330            let mut f = std::fs::File::create(&gfile).unwrap();
331            writeln!(f, "{{}}").unwrap();
332        }
333        assert_eq!(
334            resolve_transcript(HostKind::Gemini, cwd)
335                .unwrap()
336                .as_deref(),
337            Some(gfile.as_path())
338        );
339    }
340
341    #[test]
342    fn resolver_returns_none_when_home_unset() {
343        let _g = home_lock();
344        let prev = std::env::var_os("HOME");
345        unsafe {
346            std::env::remove_var("HOME");
347        }
348        // Every candidate fn bails early without HOME → empty candidate
349        // set → Ok(None) for every host arm including Auto.
350        let cwd = std::path::Path::new("/whatever");
351        assert!(
352            resolve_transcript(HostKind::ClaudeCode, cwd)
353                .unwrap()
354                .is_none()
355        );
356        assert!(resolve_transcript(HostKind::Codex, cwd).unwrap().is_none());
357        assert!(resolve_transcript(HostKind::Gemini, cwd).unwrap().is_none());
358        assert!(resolve_transcript(HostKind::Auto, cwd).unwrap().is_none());
359        if let Some(v) = prev {
360            unsafe {
361                std::env::set_var("HOME", v);
362            }
363        }
364    }
365}