1use 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
52const HISTORY_FILE: &str = "chat_history.jsonl";
54const TOOL_INPUT_CHARS: usize = 200;
56const TOOL_RESULT_CHARS: usize = 2000;
58
59#[derive(Debug, Clone)]
61pub struct GrokTranscripts {
62 sessions_dir: PathBuf,
63}
64
65impl GrokTranscripts {
66 #[must_use]
68 pub fn new(sessions_dir: PathBuf) -> Self {
69 Self { sessions_dir }
70 }
71
72 #[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
139fn 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#[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 prompt_index: Option<serde_json::Value>,
165 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 _ => {}
209 }
210 }
211 conv
212}
213
214fn 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 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 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 #[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 #[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}