Skip to main content

mj_controller/import/
transcripts.rs

1use super::*;
2
3/// Read the native JSONL only far enough to recover a transcript suitable for
4/// Hel's chat view. Full tool traffic and reasoning remain in the copied
5/// native rollout, not in this lossy projection.
6pub fn read_claude_transcript(path: &Path) -> Result<ClaudeTranscript> {
7    let body = fs::read_to_string(path)
8        .with_context(|| format!("read Claude session {}", path.display()))?;
9    let mut cwd = None;
10    let mut events = Vec::new();
11    let mut saw_raw_user = false;
12
13    for (index, line) in body.lines().enumerate() {
14        if line.trim().is_empty() {
15            continue;
16        }
17        let record: Value = serde_json::from_str(line).with_context(|| {
18            format!("parse Claude session {} line {}", path.display(), index + 1)
19        })?;
20        let recorded_at_ms = native_recorded_at_ms(&record);
21        if cwd.is_none() {
22            cwd = record
23                .get("cwd")
24                .and_then(Value::as_str)
25                .filter(|cwd| !cwd.trim().is_empty())
26                .map(PathBuf::from);
27        }
28        if record.get("isMeta").and_then(Value::as_bool) == Some(true)
29            || record.get("isSidechain").and_then(Value::as_bool) == Some(true)
30        {
31            continue;
32        }
33        let compaction_boundary = record.get("type").and_then(Value::as_str) == Some("system")
34            && matches!(
35                record.get("subtype").and_then(Value::as_str),
36                Some("compact_boundary" | "compaction")
37            );
38        let compaction_summary = record
39            .get("isCompactSummary")
40            .or_else(|| record.pointer("/message/isCompactSummary"))
41            .and_then(Value::as_bool)
42            == Some(true);
43        if compaction_boundary || compaction_summary {
44            ensure!(
45                saw_raw_user,
46                "Claude session contains a compaction artifact before recoverable raw history"
47            );
48            continue;
49        }
50        match record.get("type").and_then(Value::as_str) {
51            Some("user") => {
52                let Some(text) = record
53                    .pointer("/message/content")
54                    .and_then(Value::as_str)
55                    .map(strip_hidden_prompt_context)
56                    .filter(|text| !text.trim().is_empty())
57                else {
58                    continue;
59                };
60                let request_id = format!("import-{}", events.len() + 1);
61                push_event(
62                    &mut events,
63                    recorded_at_ms,
64                    WorkerEvent::PromptAccepted {
65                        request_id,
66                        text: text.to_owned(),
67                        attachments: Vec::new(),
68                    },
69                );
70                saw_raw_user = true;
71            }
72            Some("assistant") => {
73                let Some(content) = record.pointer("/message/content").and_then(Value::as_array)
74                else {
75                    continue;
76                };
77                for block in content {
78                    let Some(text) = block
79                        .get("text")
80                        .and_then(Value::as_str)
81                        .filter(|text| !text.is_empty())
82                    else {
83                        continue;
84                    };
85                    if block.get("type").and_then(Value::as_str) != Some("text") {
86                        continue;
87                    }
88                    push_event(
89                        &mut events,
90                        recorded_at_ms,
91                        WorkerEvent::Adapter {
92                            kind: "session_update".into(),
93                            payload: json!({
94                                "type": "session_update",
95                                "update": {
96                                    "sessionUpdate": "agent_message_chunk",
97                                    "content": {"type": "text", "text": text},
98                                },
99                            }),
100                        },
101                    );
102                }
103                // Claude marks a completed model response independently of
104                // its text/tool blocks. Preserve that lifecycle boundary so
105                // the restored durable worker is idle and accepts the next
106                // user prompt instead of treating the imported turn as live.
107                if matches!(
108                    record
109                        .pointer("/message/stop_reason")
110                        .and_then(Value::as_str),
111                    Some("end_turn" | "stop_sequence")
112                ) {
113                    push_event(&mut events, recorded_at_ms, WorkerEvent::TurnCompleted);
114                }
115            }
116            _ => {}
117        }
118    }
119
120    let cwd = cwd.context("Claude session does not declare its original cwd")?;
121    ensure!(
122        cwd.is_absolute(),
123        "Claude session cwd is not absolute: {}",
124        cwd.display()
125    );
126    finalize_import_event_times(&mut events, path)?;
127    let edited_paths = claude_edited_paths(path)?;
128    Ok(ClaudeTranscript {
129        cwd,
130        edited_paths,
131        events,
132    })
133}
134
135/// Project a Codex rollout into the canonical transcript used by Hel chat.
136pub fn read_codex_transcript(path: &Path) -> Result<CodexTranscript> {
137    let body = fs::read_to_string(path)
138        .with_context(|| format!("read Codex session {}", path.display()))?;
139    let mut cwd = None;
140    let mut history_mode = None;
141    let mut events = Vec::new();
142    let mut edited_paths = BTreeSet::new();
143    let mut saw_user = false;
144    for (index, line) in body.lines().enumerate() {
145        if line.trim().is_empty() {
146            continue;
147        }
148        let record: Value = serde_json::from_str(line).with_context(|| {
149            format!("parse Codex session {} line {}", path.display(), index + 1)
150        })?;
151        let recorded_at_ms = native_recorded_at_ms(&record);
152        if record.get("type").and_then(Value::as_str) == Some("session_meta") {
153            if cwd.is_none() {
154                cwd = record
155                    .pointer("/payload/cwd")
156                    .and_then(Value::as_str)
157                    .filter(|cwd| !cwd.trim().is_empty())
158                    .map(PathBuf::from);
159            }
160            if history_mode.is_none() {
161                history_mode = Some(
162                    record
163                        .pointer("/payload/history_mode")
164                        .and_then(Value::as_str)
165                        .map(parse_codex_history_mode)
166                        .transpose()?
167                        .unwrap_or(CodexHistoryMode::Legacy),
168                );
169            }
170            continue;
171        }
172        if record.get("type").and_then(Value::as_str) != Some("event_msg") {
173            continue;
174        }
175        if record.pointer("/payload/type").and_then(Value::as_str) == Some("item_completed")
176            && record.pointer("/payload/item/type").and_then(Value::as_str) == Some("FileChange")
177            && record
178                .pointer("/payload/item/status")
179                .and_then(Value::as_str)
180                == Some("completed")
181            && let Some(changes) = record
182                .pointer("/payload/item/changes")
183                .and_then(Value::as_object)
184        {
185            edited_paths.extend(changes.keys().map(PathBuf::from));
186        }
187        match record.pointer("/payload/type").and_then(Value::as_str) {
188            Some("item_completed")
189                if record.pointer("/payload/item/type").and_then(Value::as_str)
190                    == Some("UserMessage") =>
191            {
192                let Some(text) = codex_completed_item_text(&record) else {
193                    continue;
194                };
195                let text = strip_hidden_prompt_context(&text);
196                if text.trim().is_empty() {
197                    continue;
198                }
199                finish_imported_turn(&mut events, None);
200                let request_id = format!("import-{}", events.len() + 1);
201                push_event(
202                    &mut events,
203                    recorded_at_ms,
204                    WorkerEvent::PromptAccepted {
205                        request_id,
206                        text: text.to_owned(),
207                        attachments: Vec::new(),
208                    },
209                );
210                saw_user = true;
211            }
212            Some("item_completed")
213                if record.pointer("/payload/item/type").and_then(Value::as_str)
214                    == Some("AgentMessage") =>
215            {
216                let Some(text) = codex_completed_item_text(&record) else {
217                    continue;
218                };
219                push_event(
220                    &mut events,
221                    recorded_at_ms,
222                    WorkerEvent::Adapter {
223                        kind: "session_update".into(),
224                        payload: json!({
225                            "type": "session_update",
226                            "update": {
227                                "sessionUpdate": "agent_message_chunk",
228                                "content": {"type": "text", "text": text},
229                            },
230                        }),
231                    },
232                );
233            }
234            Some("turn_complete" | "turn_aborted") => {
235                finish_imported_turn(&mut events, recorded_at_ms)
236            }
237            _ => {}
238        }
239    }
240    ensure!(
241        history_mode == Some(CodexHistoryMode::Paginated),
242        "{CODEX_LEGACY_IMPORT_ISSUE}"
243    );
244    ensure!(
245        saw_user,
246        "Codex paginated session contains no importable user messages"
247    );
248    finish_imported_turn(&mut events, None);
249    let cwd = cwd.context("Codex session does not declare its original cwd")?;
250    ensure!(
251        cwd.is_absolute(),
252        "Codex session cwd is not absolute: {}",
253        cwd.display()
254    );
255    finalize_import_event_times(&mut events, path)?;
256    Ok(CodexTranscript {
257        cwd,
258        edited_paths: edited_paths.into_iter().collect(),
259        events,
260    })
261}
262
263pub(super) fn codex_completed_item_text(record: &Value) -> Option<String> {
264    let parts = record
265        .pointer("/payload/item/content")?
266        .as_array()?
267        .iter()
268        .filter_map(|part| part.get("text").and_then(Value::as_str))
269        .filter(|text| !text.is_empty())
270        .collect::<Vec<_>>();
271    (!parts.is_empty()).then(|| parts.join("\n"))
272}
273
274/// Project a Kimi session directory. The main wire stream contains prompts and
275/// generated text; tool traffic and thought blocks stay only in native files.
276pub fn read_kimi_transcript(session_path: &Path) -> Result<KimiTranscript> {
277    let state_path = session_path.join("state.json");
278    let state: Value = serde_json::from_slice(&fs::read(&state_path)?)
279        .with_context(|| format!("parse Kimi session state {}", state_path.display()))?;
280    let cwd = state
281        .get("workDir")
282        .or_else(|| state.get("cwd"))
283        .and_then(Value::as_str)
284        .filter(|cwd| !cwd.trim().is_empty())
285        .map(PathBuf::from)
286        .context("Kimi session state does not declare workDir or cwd")?;
287    ensure!(
288        cwd.is_absolute(),
289        "Kimi session workDir is not absolute: {}",
290        cwd.display()
291    );
292    let wire_path = session_path.join("agents/main/wire.jsonl");
293    let body = fs::read_to_string(&wire_path)
294        .with_context(|| format!("read Kimi wire stream {}", wire_path.display()))?;
295    let mut events = Vec::new();
296    let mut saw_raw_user = false;
297    for (index, line) in body.lines().enumerate() {
298        if line.trim().is_empty() {
299            continue;
300        }
301        let record: Value = serde_json::from_str(line).with_context(|| {
302            format!(
303                "parse Kimi wire stream {} line {}",
304                wire_path.display(),
305                index + 1
306            )
307        })?;
308        let recorded_at_ms = native_recorded_at_ms(&record);
309        if matches!(
310            record.get("type").and_then(Value::as_str),
311            Some("context.compaction" | "context.compacted" | "compaction")
312        ) {
313            ensure!(
314                saw_raw_user,
315                "Kimi session contains a compaction artifact before recoverable raw history"
316            );
317            continue;
318        }
319        match record.get("type").and_then(Value::as_str) {
320            Some("turn.prompt" | "turn.steer")
321                if record.pointer("/origin/kind").and_then(Value::as_str) == Some("user") =>
322            {
323                finish_imported_turn(&mut events, None);
324                let text = record
325                    .pointer("/input")
326                    .and_then(Value::as_array)
327                    .into_iter()
328                    .flatten()
329                    .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
330                    .filter_map(|part| part.get("text").and_then(Value::as_str))
331                    .filter(|text| !text.trim().is_empty())
332                    .collect::<Vec<_>>()
333                    .join("\n");
334                let text = strip_hidden_prompt_context(&text);
335                if !text.trim().is_empty() {
336                    let request_id = format!("import-{}", events.len() + 1);
337                    push_event(
338                        &mut events,
339                        recorded_at_ms,
340                        WorkerEvent::PromptAccepted {
341                            request_id,
342                            text: text.to_owned(),
343                            attachments: Vec::new(),
344                        },
345                    );
346                    saw_raw_user = true;
347                }
348            }
349            Some("context.append_loop_event")
350                if record.pointer("/event/type").and_then(Value::as_str)
351                    == Some("content.part")
352                    && record.pointer("/event/part/type").and_then(Value::as_str)
353                        == Some("text") =>
354            {
355                let Some(text) = record
356                    .pointer("/event/part/text")
357                    .and_then(Value::as_str)
358                    .filter(|text| !text.is_empty())
359                else {
360                    continue;
361                };
362                push_event(
363                    &mut events,
364                    recorded_at_ms,
365                    WorkerEvent::Adapter {
366                        kind: "session_update".into(),
367                        payload: json!({
368                            "type": "session_update",
369                            "update": {
370                                "sessionUpdate": "agent_message_chunk",
371                                "content": {"type": "text", "text": text},
372                            },
373                        }),
374                    },
375                );
376            }
377            _ => {}
378        }
379    }
380    finish_imported_turn(&mut events, None);
381    finalize_import_event_times(&mut events, &wire_path)?;
382    let edited_paths = kimi_edited_paths(session_path)?;
383    Ok(KimiTranscript {
384        cwd,
385        edited_paths,
386        events,
387    })
388}
389
390pub(super) fn claude_edited_paths(path: &Path) -> Result<Vec<PathBuf>> {
391    let mut files = vec![path.to_path_buf()];
392    if let (Some(parent), Some(session_id)) = (
393        path.parent(),
394        path.file_stem().and_then(|value| value.to_str()),
395    ) {
396        let subagents = parent.join(session_id).join("subagents");
397        if subagents.is_dir() {
398            collect_files_named(&subagents, "jsonl", &mut files)?;
399        }
400    }
401    let mut edited = BTreeSet::new();
402    for file in files {
403        let body = fs::read_to_string(&file)?;
404        let mut calls = BTreeMap::<String, PathBuf>::new();
405        let mut completed = BTreeSet::new();
406        for line in body.lines().filter(|line| !line.trim().is_empty()) {
407            let record: Value = serde_json::from_str(line)?;
408            if record.get("type").and_then(Value::as_str) == Some("file-history-delta") {
409                let Some(tracking) = record.get("trackingPath").and_then(Value::as_str) else {
410                    continue;
411                };
412                let tracking = PathBuf::from(tracking);
413                let path = if tracking.is_absolute() {
414                    tracking
415                } else if let Some(parent) = record
416                    .pointer("/backup/realParentDir")
417                    .and_then(Value::as_str)
418                {
419                    PathBuf::from(parent).join(
420                        tracking
421                            .file_name()
422                            .expect("non-empty tracking path has a file name"),
423                    )
424                } else {
425                    tracking
426                };
427                edited.insert(path);
428            }
429            if record.get("type").and_then(Value::as_str) == Some("assistant") {
430                for block in record
431                    .pointer("/message/content")
432                    .and_then(Value::as_array)
433                    .into_iter()
434                    .flatten()
435                {
436                    if block.get("type").and_then(Value::as_str) != Some("tool_use")
437                        || !matches!(
438                            block.get("name").and_then(Value::as_str),
439                            Some("Edit" | "Write" | "NotebookEdit")
440                        )
441                    {
442                        continue;
443                    }
444                    let Some(id) = block.get("id").and_then(Value::as_str) else {
445                        continue;
446                    };
447                    if let Some(path) = block
448                        .pointer("/input/file_path")
449                        .or_else(|| block.pointer("/input/notebook_path"))
450                        .or_else(|| block.pointer("/input/path"))
451                        .and_then(Value::as_str)
452                    {
453                        calls.insert(id.to_owned(), PathBuf::from(path));
454                    }
455                }
456            }
457            if record.get("type").and_then(Value::as_str) == Some("user") {
458                for block in record
459                    .pointer("/message/content")
460                    .and_then(Value::as_array)
461                    .into_iter()
462                    .flatten()
463                {
464                    if block.get("type").and_then(Value::as_str) == Some("tool_result")
465                        && block.get("is_error").and_then(Value::as_bool) != Some(true)
466                        && let Some(id) = block.get("tool_use_id").and_then(Value::as_str)
467                    {
468                        completed.insert(id.to_owned());
469                    }
470                }
471            }
472        }
473        edited.extend(
474            calls
475                .into_iter()
476                .filter(|(id, _)| completed.contains(id))
477                .map(|(_, path)| path),
478        );
479    }
480    Ok(edited.into_iter().collect())
481}
482
483pub(super) fn kimi_edited_paths(session_path: &Path) -> Result<Vec<PathBuf>> {
484    let agents = session_path.join("agents");
485    if !agents.is_dir() {
486        return Ok(Vec::new());
487    }
488    let mut files = Vec::new();
489    collect_files_named(&agents, "jsonl", &mut files)?;
490    let mut edited = BTreeSet::new();
491    for file in files {
492        let body = fs::read_to_string(file)?;
493        let mut calls = BTreeMap::<String, PathBuf>::new();
494        let mut completed = BTreeSet::new();
495        for line in body.lines().filter(|line| !line.trim().is_empty()) {
496            let record: Value = serde_json::from_str(line)?;
497            if record.get("type").and_then(Value::as_str) != Some("context.append_loop_event") {
498                continue;
499            }
500            let event = &record["event"];
501            if event.get("type").and_then(Value::as_str) == Some("tool.call")
502                && matches!(
503                    event.get("name").and_then(Value::as_str),
504                    Some("Edit" | "Write")
505                )
506                && let (Some(id), Some(path)) = (
507                    event.get("toolCallId").and_then(Value::as_str),
508                    event
509                        .pointer("/args/path")
510                        .or_else(|| event.pointer("/args/file_path"))
511                        .and_then(Value::as_str),
512                )
513            {
514                calls.insert(id.to_owned(), PathBuf::from(path));
515            }
516            if event.get("type").and_then(Value::as_str) == Some("tool.result")
517                && event.pointer("/result/isError").and_then(Value::as_bool) != Some(true)
518                && let Some(id) = event.get("toolCallId").and_then(Value::as_str)
519            {
520                completed.insert(id.to_owned());
521            }
522        }
523        edited.extend(
524            calls
525                .into_iter()
526                .filter(|(id, _)| completed.contains(id))
527                .map(|(_, path)| path),
528        );
529    }
530    Ok(edited.into_iter().collect())
531}
532
533pub(super) fn collect_files_named(
534    root: &Path,
535    extension: &str,
536    output: &mut Vec<PathBuf>,
537) -> Result<()> {
538    for entry in fs::read_dir(root)? {
539        let entry = entry?;
540        let path = entry.path();
541        let metadata = fs::symlink_metadata(&path)?;
542        if metadata.file_type().is_symlink() {
543            continue;
544        }
545        if metadata.is_dir() {
546            collect_files_named(&path, extension, output)?;
547        } else if metadata.is_file()
548            && path.extension().and_then(|value| value.to_str()) == Some(extension)
549        {
550            output.push(path);
551        }
552    }
553    Ok(())
554}
555
556pub(super) fn finish_imported_turn(events: &mut Vec<SequencedEvent>, recorded_at_ms: Option<i64>) {
557    if !events.is_empty()
558        && !matches!(
559            events.last().map(|event| &event.event),
560            Some(WorkerEvent::TurnCompleted)
561        )
562    {
563        push_event(events, recorded_at_ms, WorkerEvent::TurnCompleted);
564    }
565}
566
567pub(super) fn push_event(
568    events: &mut Vec<SequencedEvent>,
569    recorded_at_ms: Option<i64>,
570    event: WorkerEvent,
571) {
572    events.push(SequencedEvent {
573        seq: events.len() as u64 + 1,
574        recorded_at_ms,
575        request_id: None,
576        event,
577    });
578}
579
580pub(super) fn native_recorded_at_ms(record: &Value) -> Option<i64> {
581    record
582        .get("timestamp")
583        .or_else(|| record.get("time"))
584        .and_then(Value::as_str)
585        .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok())
586        .map(|timestamp| timestamp.timestamp_millis())
587}
588
589/// Native streams predate Hel's durable event clock in some harness versions.
590/// Preserve their record timestamps when available; otherwise use the source
591/// artifact's modification time. Clamping regressions keeps the imported
592/// sequence and its activity watermark monotonic even if the native clock
593/// moved backwards while the session was being recorded.
594pub(super) fn finalize_import_event_times(
595    events: &mut [SequencedEvent],
596    source_path: &Path,
597) -> Result<()> {
598    let Some(first) = events.first() else {
599        return Ok(());
600    };
601    let mut last_recorded_at_ms = match events.iter().find_map(|event| event.recorded_at_ms) {
602        Some(recorded_at_ms) => recorded_at_ms,
603        None => DateTime::<Utc>::from(
604            fs::metadata(source_path)
605                .with_context(|| format!("stat import source {}", source_path.display()))?
606                .modified()
607                .with_context(|| format!("read import source mtime {}", source_path.display()))?,
608        )
609        .timestamp_millis(),
610    };
611    last_recorded_at_ms = first
612        .recorded_at_ms
613        .unwrap_or(last_recorded_at_ms)
614        .max(last_recorded_at_ms);
615    for event in events {
616        last_recorded_at_ms = event
617            .recorded_at_ms
618            .unwrap_or(last_recorded_at_ms)
619            .max(last_recorded_at_ms);
620        event.recorded_at_ms = Some(last_recorded_at_ms);
621    }
622    Ok(())
623}