mj_controller/import/
grok.rs1use super::*;
2
3pub fn locate_grok_session(
6 home: &Path,
7 selection: &GrokSessionSelection,
8) -> Result<LocatedGrokSession> {
9 let candidates = list_grok_sessions(home)?;
10 let sessions = home.join("sessions");
11 match selection {
12 GrokSessionSelection::NativeSessionId(native_session_id) => candidates
13 .into_iter()
14 .find(|candidate| candidate.native_session_id == *native_session_id)
15 .with_context(|| {
16 format!(
17 "Grok Build session {native_session_id:?} was not found under {}",
18 sessions.display()
19 )
20 }),
21 GrokSessionSelection::Latest => candidates
22 .into_iter()
23 .next()
24 .context("no Grok Build session directories were found"),
25 }
26}
27
28pub fn list_grok_sessions(home: &Path) -> Result<Vec<LocatedGrokSession>> {
30 let mut sessions = Vec::new();
31 scan_grok_sessions(home, |progress| {
32 if let Some(session) = progress.session {
33 sessions.push(session);
34 }
35 })?;
36 Ok(sessions)
37}
38
39pub fn scan_grok_sessions(
42 home: &Path,
43 mut report: impl FnMut(SessionScanProgress<LocatedGrokSession>),
44) -> Result<()> {
45 let sessions = home.join("sessions");
46 ensure!(
47 sessions.is_dir(),
48 "Grok Build sessions directory is missing: {}",
49 sessions.display()
50 );
51 let mut candidates = grok_candidates(&sessions)?;
52 candidates.sort_by(|left, right| {
53 right
54 .modified_at
55 .cmp(&left.modified_at)
56 .then_with(|| right.session_path.cmp(&left.session_path))
57 });
58 let total = candidates.len();
59 report(SessionScanProgress {
60 scanned: 0,
61 total,
62 session: None,
63 });
64 for (index, candidate) in candidates.into_iter().enumerate() {
65 let size_bytes = directory_size(&candidate.session_path)?;
66 let session = LocatedGrokSession {
67 title: candidate.title,
68 native_session_id: candidate.native_session_id,
69 session_path: candidate.session_path,
70 modified_at: candidate.modified_at,
71 git_branch: git_branch_or_head(&candidate.cwd),
72 size_bytes,
73 cwd: candidate.cwd,
74 };
75 report(SessionScanProgress {
76 scanned: index + 1,
77 total,
78 session: Some(session),
79 });
80 }
81 Ok(())
82}
83
84fn grok_candidates(sessions: &Path) -> Result<Vec<KimiScanCandidate>> {
87 let mut candidates = Vec::new();
88 for cwd_entry in fs::read_dir(sessions)? {
89 let cwd_directory = cwd_entry?.path();
90 if !cwd_directory.is_dir() {
91 continue;
92 }
93 let decoded_cwd = grok_decode_cwd_dirname(&cwd_directory);
94 for session_entry in fs::read_dir(&cwd_directory)? {
95 let session_entry = session_entry?;
96 let session_path = session_entry.path();
97 let metadata = fs::symlink_metadata(&session_path)?;
98 if metadata.file_type().is_symlink() || !metadata.is_dir() {
99 continue;
100 }
101 let Some(native_session_id) = session_path
102 .file_name()
103 .and_then(|name| name.to_str())
104 .filter(|name| validate_id("Grok Build session", name).is_ok())
105 else {
106 continue;
107 };
108 let (title, summary_cwd) = grok_listing_metadata(&session_path);
109 let Some(cwd) = summary_cwd.or_else(|| decoded_cwd.clone()) else {
110 continue;
111 };
112 candidates.push(KimiScanCandidate {
113 native_session_id: native_session_id.to_owned(),
114 modified_at: grok_session_modified_at(&session_path, &metadata),
115 title: title.unwrap_or_else(|| native_session_id.to_owned()),
116 cwd,
117 session_path,
118 });
119 }
120 }
121 Ok(candidates)
122}
123
124pub(super) fn grok_decode_cwd_dirname(directory: &Path) -> Option<PathBuf> {
128 let name = directory.file_name()?.to_str()?;
129 if let Some(decoded) = url_decode(name)
130 && decoded.starts_with('/')
131 {
132 return Some(PathBuf::from(decoded));
133 }
134 let recorded = fs::read_to_string(directory.join(".cwd")).ok()?;
135 let recorded = recorded.trim();
136 recorded.starts_with('/').then(|| PathBuf::from(recorded))
137}
138
139fn url_decode(value: &str) -> Option<String> {
142 let bytes = value.as_bytes();
143 let mut decoded = Vec::with_capacity(bytes.len());
144 let mut index = 0;
145 while index < bytes.len() {
146 if bytes[index] == b'%' {
147 let hex = value.get(index + 1..index + 3)?;
148 decoded.push(u8::from_str_radix(hex, 16).ok()?);
149 index += 3;
150 } else {
151 decoded.push(bytes[index]);
152 index += 1;
153 }
154 }
155 String::from_utf8(decoded).ok()
156}
157
158fn grok_listing_metadata(session_path: &Path) -> (Option<String>, Option<PathBuf>) {
161 let summary = fs::read(session_path.join("summary.json"))
162 .ok()
163 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
164 .unwrap_or(Value::Null);
165 let title = summary
166 .get("session_summary")
167 .and_then(Value::as_str)
168 .and_then(normalize_session_title);
169 let cwd = summary
170 .pointer("/info/cwd")
171 .and_then(Value::as_str)
172 .map(PathBuf::from)
173 .filter(|cwd| cwd.is_absolute());
174 (title, cwd)
175}
176
177fn grok_session_modified_at(session_path: &Path, metadata: &fs::Metadata) -> SystemTime {
178 let mut modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
179 for name in [CHAT_HISTORY, "events.jsonl", "summary.json"] {
180 if let Ok(modified) = fs::metadata(session_path.join(name)).and_then(|file| file.modified())
181 {
182 modified_at = modified_at.max(modified);
183 }
184 }
185 modified_at
186}
187
188pub fn read_grok_transcript(session_path: &Path) -> Result<GrokTranscript> {
205 let summary_path = session_path.join("summary.json");
206 let summary: Value = serde_json::from_slice(&fs::read(&summary_path)?).with_context(|| {
207 format!(
208 "parse Grok Build session summary {}",
209 summary_path.display()
210 )
211 })?;
212 let cwd = summary
213 .pointer("/info/cwd")
214 .and_then(Value::as_str)
215 .filter(|cwd| !cwd.trim().is_empty())
216 .map(PathBuf::from)
217 .or_else(|| session_path.parent().and_then(grok_decode_cwd_dirname))
218 .context("Grok Build session summary does not declare its cwd")?;
219 ensure!(
220 cwd.is_absolute(),
221 "Grok Build session cwd is not absolute: {}",
222 cwd.display()
223 );
224
225 let history_path = session_path.join(CHAT_HISTORY);
226 let body = fs::read_to_string(&history_path)
227 .with_context(|| format!("read Grok Build chat history {}", history_path.display()))?;
228 let mut events = Vec::new();
229 let mut saw_raw_user = false;
230 for (index, line) in body.lines().enumerate() {
231 if line.trim().is_empty() {
232 continue;
233 }
234 let record: Value = serde_json::from_str(line).with_context(|| {
235 format!(
236 "parse Grok Build chat history {} line {}",
237 history_path.display(),
238 index + 1
239 )
240 })?;
241 let recorded_at_ms = native_recorded_at_ms(&record);
242 let item_type = record.get("type").and_then(Value::as_str);
243 if record.get("synthetic_reason").and_then(Value::as_str) == Some("compaction_meta") {
244 ensure!(
245 saw_raw_user,
246 "Grok Build session contains a compaction artifact before recoverable raw history"
247 );
248 continue;
249 }
250 match item_type {
251 Some("user") if grok_real_user_item(&record) => {
252 let text = grok_user_text(&record);
253 if text.trim().is_empty() {
254 continue;
255 }
256 finish_imported_turn(&mut events, None);
257 let request_id = format!("import-{}", events.len() + 1);
258 push_event(
259 &mut events,
260 recorded_at_ms,
261 WorkerEvent::PromptAccepted {
262 request_id,
263 text,
264 attachments: Vec::new(),
265 },
266 );
267 saw_raw_user = true;
268 }
269 Some("reasoning") => {
270 let thought = record
271 .get("summary")
272 .and_then(Value::as_array)
273 .into_iter()
274 .flatten()
275 .filter(|part| part.get("type").and_then(Value::as_str) == Some("summary_text"))
276 .filter_map(|part| part.get("text").and_then(Value::as_str))
277 .filter(|text| !text.trim().is_empty())
278 .collect::<Vec<_>>()
279 .join("\n");
280 if !thought.is_empty() {
281 push_grok_chunk(&mut events, recorded_at_ms, "agent_thought_chunk", &thought);
282 }
283 }
284 Some("assistant") => {
285 if let Some(text) = record
286 .get("content")
287 .and_then(Value::as_str)
288 .filter(|text| !text.is_empty())
289 {
290 push_grok_chunk(&mut events, recorded_at_ms, "agent_message_chunk", text);
291 }
292 }
293 _ => {}
294 }
295 }
296 finish_imported_turn(&mut events, None);
297 finalize_import_event_times(&mut events, &history_path)?;
298 let edited_paths = grok_edited_paths(&body)?;
299 Ok(GrokTranscript {
300 cwd,
301 edited_paths,
302 events,
303 })
304}
305
306fn grok_real_user_item(record: &Value) -> bool {
309 record.get("type").and_then(Value::as_str) == Some("user")
310 && record.get("synthetic_reason").is_none()
311}
312
313fn grok_user_text(record: &Value) -> String {
314 let text = record
315 .get("content")
316 .and_then(Value::as_array)
317 .into_iter()
318 .flatten()
319 .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
320 .filter_map(|part| part.get("text").and_then(Value::as_str))
321 .filter(|text| !text.trim().is_empty())
322 .collect::<Vec<_>>()
323 .join("\n");
324 strip_hidden_prompt_context(&text).to_owned()
325}
326
327fn push_grok_chunk(
328 events: &mut Vec<SequencedEvent>,
329 recorded_at_ms: Option<i64>,
330 update: &str,
331 text: &str,
332) {
333 push_event(
334 events,
335 recorded_at_ms,
336 WorkerEvent::Adapter {
337 kind: "session_update".into(),
338 payload: json!({
339 "type": "session_update",
340 "update": {
341 "sessionUpdate": update,
342 "content": {"type": "text", "text": text},
343 },
344 }),
345 },
346 );
347}
348
349pub(super) fn grok_edited_paths(history: &str) -> Result<Vec<PathBuf>> {
355 let mut calls = BTreeMap::<String, PathBuf>::new();
356 let mut completed = BTreeSet::new();
357 for line in history.lines().filter(|line| !line.trim().is_empty()) {
358 let record: Value = serde_json::from_str(line)?;
359 match record.get("type").and_then(Value::as_str) {
360 Some("assistant") => {
361 for call in record
362 .get("tool_calls")
363 .and_then(Value::as_array)
364 .into_iter()
365 .flatten()
366 {
367 if call.get("name").and_then(Value::as_str) != Some("search_replace") {
368 continue;
369 }
370 let Some(id) = call.get("id").and_then(Value::as_str) else {
371 continue;
372 };
373 let arguments = call
374 .get("arguments")
375 .and_then(Value::as_str)
376 .and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
377 .unwrap_or(Value::Null);
378 if let Some(path) = arguments.get("file_path").and_then(Value::as_str) {
379 calls.insert(id.to_owned(), PathBuf::from(path));
380 }
381 }
382 }
383 Some("tool_result") => {
384 if let Some(id) = record.get("tool_call_id").and_then(Value::as_str) {
385 completed.insert(id.to_owned());
386 }
387 }
388 _ => {}
389 }
390 }
391 Ok(calls
392 .into_iter()
393 .filter(|(id, _)| completed.contains(id))
394 .map(|(_, path)| path)
395 .collect())
396}