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