Skip to main content

recall_echo/transcript/
grok.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//! Grok CLI transcripts —
6//! `~/.grok/sessions/<percent-encoded cwd>/<session uuid>/chat_history.jsonl`.
7//!
8//! Two things about the layout. The first directory level is the working
9//! directory the session ran in, percent-encoded (`%2Fopt%2Frecall-echo`), so
10//! discovery decodes it rather than reporting a mangled path. The second is
11//! that each session directory also holds `prompt_history.jsonl`, which
12//! contains only the human's prompts — tempting, and wrong: a conversation with
13//! the model's side missing is not a conversation.
14//!
15//! # Shapes
16//!
17//! ```text
18//! {"type":"system",     "content":"<string>"}                    harness
19//! {"type":"user",       "content":[{"type":"text","text":"…"}]}  array!
20//! {"type":"assistant",  "content":"OK", "tool_calls":[…]}        string!
21//! {"type":"reasoning",  "summary":[{"type":"summary_text",…}]}   private
22//! {"type":"tool_result","content":"exit: 0\n…"}
23//! ```
24//!
25//! `content` is an array on user turns and a bare string on assistant turns —
26//! in the same file. `reasoning` is the model's private thinking; recording it
27//! would put unasserted thoughts into memory as though the model had said them,
28//! the same hazard the Claude Code parser avoids by dropping thinking blocks.
29//!
30//! # Which user turns are real
31//!
32//! Grok injects context under `type: "user"`: a `<user_info>` preamble, project
33//! instructions, skill and MCP reminders. It marks them — injected records
34//! carry `synthetic_reason`, and a real prompt carries `prompt_index` — so the
35//! rule is: when a file marks any prompt with `prompt_index`, only those are
36//! turns; when none does, everything without a `synthetic_reason` is. The
37//! prompt itself arrives wrapped in `<user_query>`, which is framing for the
38//! model and is unwrapped.
39
40use std::path::{Path, PathBuf};
41use std::time::SystemTime;
42
43use serde::Deserialize;
44
45use super::{
46    content_text, iso_timestamp, modified_at, newer_than, percent_decode, unwrap_tag, Source,
47    Transcript, TranscriptRef,
48};
49use crate::conversation::{truncate, Conversation, ConversationEntry};
50use crate::error::RecallError;
51
52/// The one file in a session directory that holds both sides of the exchange.
53const HISTORY_FILE: &str = "chat_history.jsonl";
54/// Characters of a tool call's arguments kept in the archive.
55const TOOL_INPUT_CHARS: usize = 200;
56/// Characters of a tool result kept in the archive.
57const TOOL_RESULT_CHARS: usize = 2000;
58
59/// Grok's session records.
60#[derive(Debug, Clone)]
61pub struct GrokTranscripts {
62    sessions_dir: PathBuf,
63}
64
65impl GrokTranscripts {
66    /// Read sessions from an explicit `sessions/` directory.
67    #[must_use]
68    pub fn new(sessions_dir: PathBuf) -> Self {
69        Self { sessions_dir }
70    }
71
72    /// Read sessions from this machine's Grok installation.
73    #[must_use]
74    pub fn detect() -> Option<Self> {
75        Some(Self::new(dirs::home_dir()?.join(".grok").join("sessions")))
76    }
77}
78
79impl Transcript for GrokTranscripts {
80    fn source(&self) -> Source {
81        Source::Grok
82    }
83
84    fn sessions_root(&self) -> &Path {
85        &self.sessions_dir
86    }
87
88    fn discover(&self, since: Option<SystemTime>) -> Result<Vec<TranscriptRef>, RecallError> {
89        let mut found = Vec::new();
90        let Ok(workspaces) = std::fs::read_dir(&self.sessions_dir) else {
91            return Ok(Vec::new());
92        };
93
94        for workspace in workspaces.flatten() {
95            let workspace_path = workspace.path();
96            if !workspace_path.is_dir() {
97                continue;
98            }
99            let cwd = workspace
100                .file_name()
101                .to_str()
102                .map(percent_decode)
103                .filter(|decoded| !decoded.is_empty());
104
105            let Ok(sessions) = std::fs::read_dir(&workspace_path) else {
106                continue;
107            };
108            for session in sessions.flatten() {
109                let history = session.path().join(HISTORY_FILE);
110                if !history.is_file() {
111                    continue;
112                }
113                let Some(session_id) = session.file_name().to_str().map(str::to_string) else {
114                    continue;
115                };
116                found.push(TranscriptRef {
117                    source: Source::Grok,
118                    session_id,
119                    modified: modified_at(&history),
120                    path: history,
121                    cwd: cwd.clone(),
122                });
123            }
124        }
125
126        Ok(newer_than(found, since))
127    }
128
129    fn parse(&self, transcript: &TranscriptRef) -> Result<Conversation, RecallError> {
130        let raw = std::fs::read_to_string(&transcript.path)?;
131        let mut conv = parse_history(&raw, &transcript.session_id);
132        let (started, ended) = file_span(&transcript.path);
133        conv.first_timestamp = Some(iso_timestamp(started));
134        conv.last_timestamp = Some(iso_timestamp(ended));
135        Ok(conv)
136    }
137}
138
139/// When the session started and last moved.
140///
141/// `chat_history.jsonl` carries no timestamps at all, so the file's own times
142/// are the only evidence of when the conversation happened — and they are
143/// honest ones: the file is created when the session opens and appended to on
144/// every turn. Filesystems that do not record a creation time fall back to the
145/// last write, which makes the duration zero rather than wrong.
146fn file_span(path: &Path) -> (SystemTime, SystemTime) {
147    let modified = modified_at(path);
148    let created = std::fs::metadata(path)
149        .and_then(|meta| meta.created())
150        .unwrap_or(modified);
151    (created.min(modified), modified)
152}
153
154// ── Line model ───────────────────────────────────────────────────────────
155
156#[derive(Deserialize)]
157struct ChatLine {
158    #[serde(rename = "type")]
159    kind: String,
160    content: Option<serde_json::Value>,
161    #[serde(default)]
162    tool_calls: Vec<ToolCall>,
163    /// Present on the human's own prompts.
164    prompt_index: Option<serde_json::Value>,
165    /// Present on harness-injected user records.
166    synthetic_reason: Option<String>,
167}
168
169#[derive(Deserialize)]
170struct ToolCall {
171    name: Option<String>,
172    arguments: Option<serde_json::Value>,
173}
174
175fn parse_history(raw: &str, session_id: &str) -> Conversation {
176    let lines: Vec<ChatLine> = raw
177        .lines()
178        .filter(|line| !line.trim().is_empty())
179        .filter_map(|line| match serde_json::from_str(line) {
180            Ok(parsed) => Some(parsed),
181            Err(_) => {
182                eprintln!("recall-echo: skipping malformed grok line");
183                None
184            }
185        })
186        .collect();
187
188    let prompts_are_marked = lines
189        .iter()
190        .any(|line| line.kind == "user" && line.prompt_index.is_some());
191
192    let mut conv = Conversation::new(session_id);
193    for line in &lines {
194        match line.kind.as_str() {
195            "user" if is_real_prompt(line, prompts_are_marked) => {
196                let text = unwrap_tag(&line_text(line), "user_query");
197                if !text.trim().is_empty() {
198                    conv.user_message_count += 1;
199                    conv.entries.push(ConversationEntry::UserMessage(text));
200                }
201            }
202            "assistant" => push_assistant(&mut conv, line),
203            "tool_result" => conv.entries.push(ConversationEntry::ToolResult {
204                content: truncate(line_text(line).trim(), TOOL_RESULT_CHARS),
205                is_error: false,
206            }),
207            // "system" is the harness prompt; "reasoning" is private thinking.
208            _ => {}
209        }
210    }
211    conv
212}
213
214/// Whether a `user` record is the human speaking.
215fn is_real_prompt(line: &ChatLine, prompts_are_marked: bool) -> bool {
216    if prompts_are_marked {
217        line.prompt_index.is_some()
218    } else {
219        line.synthetic_reason.is_none()
220    }
221}
222
223fn push_assistant(conv: &mut Conversation, line: &ChatLine) {
224    let text = line_text(line);
225    if !text.trim().is_empty() {
226        conv.assistant_message_count += 1;
227        conv.entries.push(ConversationEntry::AssistantText(text));
228    }
229    for call in &line.tool_calls {
230        let arguments = call
231            .arguments
232            .as_ref()
233            .map(content_text)
234            .unwrap_or_default();
235        conv.entries.push(ConversationEntry::ToolUse {
236            name: call.name.clone().unwrap_or_else(|| "unknown".to_string()),
237            input_summary: truncate(arguments.trim(), TOOL_INPUT_CHARS),
238        });
239    }
240}
241
242fn line_text(line: &ChatLine) -> String {
243    line.content.as_ref().map(content_text).unwrap_or_default()
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    /// Real shapes from a Grok 4.5 `chat_history.jsonl`, scrubbed: the system
251    /// prompt, an unmarked `<user_info>` preamble, two synthetic reminders, the
252    /// real prompt, a reasoning record, an assistant turn with a tool call, a
253    /// tool result, and the final answer.
254    const HISTORY: &str = concat!(
255        r#"{"type":"system","content":"You are Grok 4.5 released by xAI. Complete the user's request."}"#,
256        "\n",
257        r#"{"type":"user","content":[{"type":"text","text":"<user_info>\nOS Version: linux\nWorkspace Path: /tmp/probe\n</user_info>"}]}"#,
258        "\n",
259        r#"{"type":"user","content":[{"type":"text","text":"<system-reminder>project instructions</system-reminder>"}],"synthetic_reason":"project_instructions"}"#,
260        "\n",
261        r#"{"type":"user","content":[{"type":"text","text":"<system-reminder>skills available</system-reminder>"}],"synthetic_reason":"system_reminder"}"#,
262        "\n",
263        r#"{"type":"user","content":[{"type":"text","text":"<user_query>\nList files, then reply DONE\n</user_query>"}],"prompt_index":0}"#,
264        "\n",
265        r#"{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"The user wants a directory listing."}],"status":"completed"}"#,
266        "\n",
267        r#"{"type":"assistant","content":"I'll list the files.","tool_calls":[{"id":"call-1","name":"run_terminal_command","arguments":"{\"command\":\"ls -la\"}"}],"model_id":"grok-4.5-build"}"#,
268        "\n",
269        r#"{"type":"tool_result","tool_call_id":"call-1","content":"exit: 0\nREADME.md\n"}"#,
270        "\n",
271        r#"{"type":"assistant","content":"DONE","model_id":"grok-4.5-build"}"#,
272        "\n",
273    );
274
275    fn fixture_tree() -> tempfile::TempDir {
276        let tmp = tempfile::tempdir().unwrap();
277        let session = tmp.path().join("%2Ftmp%2Fprobe").join("019fd40b-8e19-7742");
278        std::fs::create_dir_all(&session).unwrap();
279        std::fs::write(session.join(HISTORY_FILE), HISTORY).unwrap();
280        // The sibling file that must never be mistaken for a transcript.
281        std::fs::write(
282            tmp.path()
283                .join("%2Ftmp%2Fprobe")
284                .join("prompt_history.jsonl"),
285            "{\"prompt\":\"List files\"}\n",
286        )
287        .unwrap();
288        tmp
289    }
290
291    fn parsed() -> Conversation {
292        let tmp = fixture_tree();
293        let adapter = GrokTranscripts::new(tmp.path().to_path_buf());
294        let found = adapter.discover(None).unwrap();
295        adapter.parse(&found[0]).unwrap()
296    }
297
298    #[test]
299    fn discovery_decodes_the_workspace_directory_and_ignores_prompt_history() {
300        let tmp = fixture_tree();
301        let adapter = GrokTranscripts::new(tmp.path().to_path_buf());
302
303        let found = adapter.discover(None).unwrap();
304        assert_eq!(found.len(), 1);
305        assert_eq!(found[0].session_id, "019fd40b-8e19-7742");
306        assert_eq!(found[0].cwd.as_deref(), Some("/tmp/probe"));
307        assert!(found[0].path.ends_with(HISTORY_FILE));
308    }
309
310    /// The array/string trap: both sides parse, in one file.
311    #[test]
312    fn user_content_arrays_and_assistant_content_strings_both_parse() {
313        let conv = parsed();
314        assert_eq!(conv.user_message_count, 1);
315        assert_eq!(conv.assistant_message_count, 2);
316        match &conv.entries[0] {
317            ConversationEntry::UserMessage(text) => {
318                assert_eq!(text, "List files, then reply DONE");
319            }
320            other => panic!("expected the user turn first, got {other:?}"),
321        }
322    }
323
324    #[test]
325    fn the_system_prompt_and_the_injected_reminders_are_not_turns() {
326        let conv = parsed();
327        let markdown = crate::conversation::conversation_to_markdown(&conv, 1);
328        assert!(!markdown.contains("You are Grok"), "{markdown}");
329        assert!(!markdown.contains("project instructions"), "{markdown}");
330        assert!(!markdown.contains("user_info"), "{markdown}");
331    }
332
333    #[test]
334    fn private_reasoning_never_reaches_the_archive() {
335        let conv = parsed();
336        let markdown = crate::conversation::conversation_to_markdown(&conv, 1);
337        assert!(!markdown.contains("directory listing"), "{markdown}");
338    }
339
340    #[test]
341    fn tool_calls_and_results_survive() {
342        let conv = parsed();
343        let calls: Vec<&ConversationEntry> = conv
344            .entries
345            .iter()
346            .filter(|e| matches!(e, ConversationEntry::ToolUse { .. }))
347            .collect();
348        assert_eq!(calls.len(), 1);
349        match calls[0] {
350            ConversationEntry::ToolUse {
351                name,
352                input_summary,
353            } => {
354                assert_eq!(name, "run_terminal_command");
355                assert!(input_summary.contains("ls -la"), "{input_summary}");
356            }
357            other => panic!("expected a tool call, got {other:?}"),
358        }
359        assert!(conv
360            .entries
361            .iter()
362            .any(|e| matches!(e, ConversationEntry::ToolResult { content, .. } if content.contains("README.md"))));
363    }
364
365    /// An older transcript with no `prompt_index` anywhere: fall back to
366    /// "everything not marked synthetic", rather than capturing no human side.
367    #[test]
368    fn unmarked_transcripts_fall_back_to_the_synthetic_flag() {
369        let raw = concat!(
370            r#"{"type":"user","content":[{"type":"text","text":"<system-reminder>injected</system-reminder>"}],"synthetic_reason":"system_reminder"}"#,
371            "\n",
372            r#"{"type":"user","content":[{"type":"text","text":"a real question"}]}"#,
373            "\n",
374        );
375        let conv = parse_history(raw, "s");
376        assert_eq!(conv.user_message_count, 1);
377        match &conv.entries[0] {
378            ConversationEntry::UserMessage(text) => assert_eq!(text, "a real question"),
379            other => panic!("expected the unmarked prompt, got {other:?}"),
380        }
381    }
382
383    #[test]
384    fn timestamps_come_from_the_file_because_the_format_has_none() {
385        let conv = parsed();
386        let first = conv.first_timestamp.expect("a start time");
387        let last = conv.last_timestamp.expect("an end time");
388        assert!(first.ends_with('Z'), "{first}");
389        assert!(last.ends_with('Z'), "{last}");
390        assert!(first <= last, "{first} .. {last}");
391    }
392
393    #[test]
394    fn a_malformed_line_does_not_lose_the_session() {
395        let raw = concat!(
396            "}{ not json\n",
397            r#"{"type":"user","content":[{"type":"text","text":"still here"}],"prompt_index":0}"#,
398            "\n",
399        );
400        let conv = parse_history(raw, "s");
401        assert_eq!(conv.user_message_count, 1);
402    }
403}