1use super::*;
2
3pub 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 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
135pub 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
274pub 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
589pub(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}