Skip to main content

recall_echo/
transcript.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Transcript adapters — reading any agent CLI's session records.
6//!
7//! recall-echo's claim is that the memory lifecycle is mechanical rather than
8//! on the honor system. Until this module existed that was true for exactly one
9//! editor: `init` installed hooks into Claude Code, and [`crate::jsonl`] parsed
10//! Claude Code's transcript format, so a Codex or Grok user could *read* memory
11//! over MCP while nothing ever wrote any.
12//!
13//! Every agent CLI already records its sessions to disk. An adapter says where
14//! those records live and how to read one; everything downstream — archival,
15//! EPHEMERAL.md, graph ingest, per-turn provenance — is unchanged, because an
16//! adapter's only output is the [`Conversation`] the rest of the crate already
17//! speaks.
18//!
19//! # The contract every adapter owes
20//!
21//! [`Transcript::parse`] returns *what the two parties said*, and nothing else:
22//!
23//! - a human turn becomes [`ConversationEntry::UserMessage`] — `user` evidence
24//!   to the confidence model,
25//! - a model turn becomes [`ConversationEntry::AssistantText`] — `self`
26//!   evidence, worth far less,
27//! - harness text is not a turn at all. System prompts, developer instructions,
28//!   injected reminders and private reasoning are dropped.
29//!
30//! That last line is the whole reason provenance means anything. A system
31//! prompt recorded under `role: "user"` would enter the graph as something the
32//! user asserted, and the model's own unasserted thinking would enter as
33//! something it concluded. Each adapter documents the *verified* signal it uses
34//! to tell a real turn from an injected one.
35
36pub mod claude_code;
37pub mod codex;
38pub mod grok;
39
40use std::fmt;
41use std::path::{Path, PathBuf};
42use std::time::SystemTime;
43
44use serde::{Deserialize, Serialize};
45
46use crate::conversation::Conversation;
47use crate::error::RecallError;
48
49pub use claude_code::ClaudeCodeTranscripts;
50pub use codex::CodexTranscripts;
51pub use grok::GrokTranscripts;
52
53/// How deep discovery walks below a CLI's session root.
54///
55/// Codex nests by `YYYY/MM/DD`, Grok by `<encoded cwd>/<session>`, Claude Code
56/// by project directory. Four levels covers all three with room to spare, and
57/// bounds the walk on a directory that is not what we think it is.
58const MAX_DISCOVERY_DEPTH: usize = 4;
59
60// ── Source ───────────────────────────────────────────────────────────────
61
62/// An agent CLI whose transcripts recall-echo can read.
63///
64/// The string form is the one used everywhere a human names a CLI:
65/// `[capture] sources`, `recall-echo ingest --from`, and the `source:` field of
66/// an archive's frontmatter.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum Source {
70    ClaudeCode,
71    Codex,
72    Grok,
73}
74
75impl Source {
76    /// Every CLI with an adapter, in a stable order.
77    pub const ALL: [Source; 3] = [Source::ClaudeCode, Source::Codex, Source::Grok];
78
79    #[must_use]
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            Source::ClaudeCode => "claude-code",
83            Source::Codex => "codex",
84            Source::Grok => "grok",
85        }
86    }
87
88    pub fn from_str_loose(s: &str) -> Result<Self, RecallError> {
89        match s.trim().to_lowercase().as_str() {
90            "claude-code" | "claudecode" | "claude" => Ok(Source::ClaudeCode),
91            "codex" | "codex-cli" => Ok(Source::Codex),
92            "grok" | "grok-cli" => Ok(Source::Grok),
93            other => Err(RecallError::Config(format!(
94                "unknown transcript source: {other} (use 'claude-code', 'codex', or 'grok')"
95            ))),
96        }
97    }
98}
99
100impl fmt::Display for Source {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106// ── A discovered session ─────────────────────────────────────────────────
107
108/// One session record on disk, as discovery found it.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TranscriptRef {
111    pub source: Source,
112    /// The CLI's own session identifier — what archives are deduplicated on.
113    pub session_id: String,
114    pub path: PathBuf,
115    /// Last write. Both the ordering key and the watermark.
116    pub modified: SystemTime,
117    /// Working directory the session ran in, when the CLI records one.
118    pub cwd: Option<String>,
119}
120
121impl TranscriptRef {
122    /// Age at `now`, or zero for a file written in the future (clock skew).
123    #[must_use]
124    pub fn age_at(&self, now: SystemTime) -> std::time::Duration {
125        now.duration_since(self.modified).unwrap_or_default()
126    }
127}
128
129// ── The adapter ──────────────────────────────────────────────────────────
130
131/// A CLI's on-disk session records, as recall-echo reads them.
132///
133/// Implementors are constructed against an explicit root, so a test drives one
134/// over a tempdir tree and the real thing over `$HOME`.
135pub trait Transcript: Send + Sync {
136    /// Which CLI this adapter reads.
137    fn source(&self) -> Source;
138
139    /// Directory the CLI records sessions in — whether or not it exists.
140    fn sessions_root(&self) -> &Path;
141
142    /// Sessions written strictly after `since`, oldest first.
143    ///
144    /// A missing root is not an error: a CLI that has never run has no
145    /// sessions, which is exactly an empty list.
146    fn discover(&self, since: Option<SystemTime>) -> Result<Vec<TranscriptRef>, RecallError>;
147
148    /// Read one discovered session into the universal conversation format.
149    fn parse(&self, transcript: &TranscriptRef) -> Result<Conversation, RecallError>;
150
151    /// True when this CLI has recorded at least one session on this machine.
152    fn is_installed(&self) -> bool {
153        self.sessions_root().exists()
154    }
155}
156
157/// The adapter for one source, rooted at that CLI's default location.
158///
159/// `None` when the home directory cannot be determined — the only case in
160/// which no adapter can be built at all.
161#[must_use]
162pub fn adapter_for(source: Source) -> Option<Box<dyn Transcript>> {
163    match source {
164        Source::ClaudeCode => ClaudeCodeTranscripts::detect().map(boxed),
165        Source::Codex => CodexTranscripts::detect().map(boxed),
166        Source::Grok => GrokTranscripts::detect().map(boxed),
167    }
168}
169
170fn boxed<T: Transcript + 'static>(adapter: T) -> Box<dyn Transcript> {
171    Box::new(adapter)
172}
173
174/// Every adapter whose CLI has actually recorded sessions here.
175///
176/// This is what `[capture] sources` defaults to: capture from the CLIs the user
177/// demonstrably uses, and stay silent about the ones they do not.
178#[must_use]
179pub fn detect_installed() -> Vec<Box<dyn Transcript>> {
180    Source::ALL
181        .iter()
182        .filter_map(|source| adapter_for(*source))
183        .filter(|adapter| adapter.is_installed())
184        .collect()
185}
186
187// ── Shared parsing helpers ───────────────────────────────────────────────
188
189/// The text of a content field, whichever shape the CLI chose for it.
190///
191/// This is not defensive coding, it is the actual disagreement: within a single
192/// Grok transcript a user turn's `content` is an array of `{type,text}` blocks
193/// and an assistant turn's `content` is a bare string. Codex always uses an
194/// array of `input_text` / `output_text` blocks. One helper, so no adapter has
195/// to care twice.
196pub(crate) fn content_text(value: &serde_json::Value) -> String {
197    match value {
198        serde_json::Value::String(text) => text.clone(),
199        serde_json::Value::Array(blocks) => {
200            let parts: Vec<String> = blocks.iter().map(content_text).collect();
201            parts
202                .iter()
203                .filter(|part| !part.is_empty())
204                .cloned()
205                .collect::<Vec<_>>()
206                .join("")
207        }
208        serde_json::Value::Object(map) => map
209            .get("text")
210            .and_then(|text| text.as_str())
211            .unwrap_or_default()
212            .to_string(),
213        _ => String::new(),
214    }
215}
216
217/// Strip `<tag>` … `</tag>` when the text is entirely that wrapper.
218///
219/// Grok wraps the human's prompt in `<user_query>`; the wrapper is addressed to
220/// the model, not part of what the human said.
221pub(crate) fn unwrap_tag(text: &str, tag: &str) -> String {
222    let open = format!("<{tag}>");
223    let close = format!("</{tag}>");
224    let trimmed = text.trim();
225    match trimmed
226        .strip_prefix(&open)
227        .and_then(|rest| rest.strip_suffix(&close))
228    {
229        Some(inner) => inner.trim().to_string(),
230        None => text.to_string(),
231    }
232}
233
234/// Decode a percent-encoded path segment.
235///
236/// Grok names each session directory after the working directory it ran in,
237/// percent-encoded (`%2Froot`). Undoing that is a few lines of hex, which is
238/// cheaper than a dependency and cannot drift from what we need it to do.
239/// Invalid escapes are left verbatim rather than dropped, so a decode failure
240/// degrades to a slightly ugly label instead of a wrong path.
241#[must_use]
242pub(crate) fn percent_decode(encoded: &str) -> String {
243    let bytes = encoded.as_bytes();
244    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
245    let mut index = 0;
246    while index < bytes.len() {
247        if bytes[index] == b'%' && index + 2 < bytes.len() {
248            let hex = &encoded[index + 1..index + 3];
249            if let Ok(byte) = u8::from_str_radix(hex, 16) {
250                out.push(byte);
251                index += 3;
252                continue;
253            }
254        }
255        out.push(bytes[index]);
256        index += 1;
257    }
258    String::from_utf8(out).unwrap_or_else(|_| encoded.to_string())
259}
260
261/// A filesystem timestamp as the ISO 8601 string conversations use.
262pub(crate) fn iso_timestamp(time: SystemTime) -> String {
263    chrono::DateTime::<chrono::Utc>::from(time)
264        .format("%Y-%m-%dT%H:%M:%SZ")
265        .to_string()
266}
267
268/// Last-write time, or the epoch when the filesystem will not say.
269pub(crate) fn modified_at(path: &Path) -> SystemTime {
270    std::fs::metadata(path)
271        .and_then(|meta| meta.modified())
272        .unwrap_or(SystemTime::UNIX_EPOCH)
273}
274
275/// Files with the given extension under `dir`, walking at most
276/// [`MAX_DISCOVERY_DEPTH`] levels. Unreadable directories are skipped.
277pub(crate) fn walk_files(dir: &Path, extension: &str, depth: usize) -> Vec<PathBuf> {
278    if depth > MAX_DISCOVERY_DEPTH {
279        return Vec::new();
280    }
281    let Ok(entries) = std::fs::read_dir(dir) else {
282        return Vec::new();
283    };
284    let mut files = Vec::new();
285    for entry in entries.flatten() {
286        let path = entry.path();
287        if path.is_dir() {
288            files.extend(walk_files(&path, extension, depth + 1));
289        } else if path.extension().is_some_and(|ext| ext == extension) {
290            files.push(path);
291        }
292    }
293    files
294}
295
296/// Order oldest-first and drop anything at or before the watermark.
297///
298/// Oldest-first matters downstream: archives are numbered in the order they are
299/// written, so ingesting in write order keeps conversation numbers in the same
300/// order the conversations happened.
301pub(crate) fn newer_than(
302    mut found: Vec<TranscriptRef>,
303    since: Option<SystemTime>,
304) -> Vec<TranscriptRef> {
305    if let Some(watermark) = since {
306        found.retain(|transcript| transcript.modified > watermark);
307    }
308    found.sort_by(|a, b| {
309        a.modified
310            .cmp(&b.modified)
311            .then_with(|| a.session_id.cmp(&b.session_id))
312    });
313    found
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn source_names_round_trip() {
322        for source in Source::ALL {
323            assert_eq!(Source::from_str_loose(source.as_str()).unwrap(), source);
324        }
325        assert_eq!(
326            Source::from_str_loose("  CLAUDE  ").unwrap(),
327            Source::ClaudeCode
328        );
329        assert!(Source::from_str_loose("cursor").is_err());
330    }
331
332    #[test]
333    fn content_text_reads_a_bare_string() {
334        assert_eq!(content_text(&serde_json::json!("OK")), "OK");
335    }
336
337    /// Grok's own trap: user content is an array, assistant content is a
338    /// string, in the same file.
339    #[test]
340    fn content_text_reads_an_array_of_blocks() {
341        let value = serde_json::json!([
342            {"type": "text", "text": "first"},
343            {"type": "text", "text": " second"},
344        ]);
345        assert_eq!(content_text(&value), "first second");
346    }
347
348    #[test]
349    fn content_text_ignores_blocks_without_text() {
350        let value = serde_json::json!([{"type": "image", "url": "http://x"}, {"text": "kept"}]);
351        assert_eq!(content_text(&value), "kept");
352    }
353
354    #[test]
355    fn unwrap_tag_strips_only_a_whole_wrapper() {
356        assert_eq!(
357            unwrap_tag("<user_query>\nhello\n</user_query>", "user_query"),
358            "hello"
359        );
360        assert_eq!(
361            unwrap_tag("prefix <user_query>hello</user_query>", "user_query"),
362            "prefix <user_query>hello</user_query>"
363        );
364    }
365
366    #[test]
367    fn percent_decode_handles_grok_session_dirs() {
368        assert_eq!(percent_decode("%2Froot"), "/root");
369        assert_eq!(percent_decode("%2Fopt%2Frecall-echo"), "/opt/recall-echo");
370        assert_eq!(percent_decode("plain"), "plain");
371        // A stray percent is data, not an escape.
372        assert_eq!(percent_decode("100%"), "100%");
373        assert_eq!(percent_decode("%zz"), "%zz");
374    }
375
376    #[test]
377    fn newer_than_drops_the_watermark_itself_and_sorts_oldest_first() {
378        let epoch = SystemTime::UNIX_EPOCH;
379        let make = |id: &str, secs: u64| TranscriptRef {
380            source: Source::Codex,
381            session_id: id.to_string(),
382            path: PathBuf::from(format!("/tmp/{id}")),
383            modified: epoch + std::time::Duration::from_secs(secs),
384            cwd: None,
385        };
386        let found = vec![make("c", 30), make("a", 10), make("b", 20)];
387        let kept = newer_than(found, Some(epoch + std::time::Duration::from_secs(10)));
388        let ids: Vec<&str> = kept.iter().map(|t| t.session_id.as_str()).collect();
389        assert_eq!(ids, ["b", "c"]);
390    }
391
392    #[test]
393    fn walking_a_missing_directory_finds_nothing() {
394        assert!(walk_files(Path::new("/nonexistent/nowhere"), "jsonl", 0).is_empty());
395    }
396
397    #[test]
398    fn walking_finds_nested_files_and_ignores_other_extensions() {
399        let tmp = tempfile::tempdir().unwrap();
400        let nested = tmp.path().join("2026/08/05");
401        std::fs::create_dir_all(&nested).unwrap();
402        std::fs::write(nested.join("rollout-a.jsonl"), "").unwrap();
403        std::fs::write(nested.join("notes.txt"), "").unwrap();
404
405        let found = walk_files(tmp.path(), "jsonl", 0);
406        assert_eq!(found.len(), 1);
407        assert!(found[0].ends_with("rollout-a.jsonl"));
408    }
409}