Skip to main content

mj_controller/import/
grok.rs

1use super::*;
2
3/// Locate a Grok Build session directory. Its name is the session UUID, which
4/// is the native identifier `session/load` takes.
5pub 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
28/// List native Grok Build sessions newest first.
29pub 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
39/// Scan native Grok Build sessions newest first, reporting after every
40/// candidate directory.
41pub 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
84/// Walk `sessions/<encoded-cwd>/<session-uuid>`. The sessions root also holds
85/// the shared search index and lock files, which are not sessions.
86fn 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
124/// Grok Build's session-directory name is the URL-encoded working directory,
125/// or a `{slug}-{hash}` form for a long path that keeps the real value in a
126/// `.cwd` file. Mirrors `decode_cwd_from_dirname` in grok-build.
127pub(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
139/// Percent-decoding for the session-directory name. `None` when the text is
140/// not valid percent-encoded UTF-8, which means it is not a cwd key.
141fn 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
158/// Listing title and cwd from `summary.json`. Never fails the whole scan: an
159/// unreadable session is listed with what could be recovered.
160fn 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
188/// Project a Grok Build session directory. `chat_history.jsonl` is the
189/// conversation of record: one JSON object per line, internally tagged by
190/// `type`.
191///
192/// * `system` — the rendered system prompt; not conversation.
193/// * `user` — `content` is a list of `{type: "text"|"image", ...}` parts. A
194///   `synthetic_reason` marks a message the runtime injected rather than one a
195///   person typed.
196/// * `assistant` — `content` is the response text, with any `tool_calls` as
197///   `{id, name, arguments}` beside it.
198/// * `reasoning` — an inlined Responses-API reasoning item whose `summary`
199///   holds `{type: "summary_text", text}` parts.
200/// * `tool_result` — `{tool_call_id, content}`.
201///
202/// Compaction rewrites the file in place, so a `compaction_meta` message with
203/// no earlier real prompt means the raw history is already gone.
204pub 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
306/// A `user` item a person actually typed. Everything the runtime injects
307/// carries a `synthetic_reason`.
308fn 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
349/// Files `search_replace` — Grok Build's only file-writing tool — was asked to
350/// change, kept only when a matching `tool_result` proves the call ran. A tool
351/// result carries no success flag, so a call that ran and failed is still
352/// counted; over-reporting an edit only widens the set of repositories the
353/// import inspects.
354pub(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}