1use serde::Deserialize;
16use zeph_llm::provider::Message;
17
18use crate::MemoryError;
19use crate::graph::ingest::document::IngestSourceKind;
20use crate::graph::types::{GraphOrigin, GraphProvenance};
21
22use super::document::IngestDocument;
23use super::report::ImportBatchId;
24
25mod sealed {
26 pub trait Sealed {}
27}
28
29fn build_ingest_documents(
35 texts: &[String],
36 ids: &[String],
37 context_window: usize,
38 batch_id: &ImportBatchId,
39 origin: GraphOrigin,
40 source_uri: impl Fn(&str) -> String,
41) -> Vec<IngestDocument> {
42 let mut docs = Vec::with_capacity(texts.len());
43 for (i, (content, id)) in texts.iter().zip(ids.iter()).enumerate() {
44 if content.trim().is_empty() {
45 continue;
46 }
47 let start = i.saturating_sub(context_window);
48 let ctx: Vec<String> = texts[start..i].to_vec();
49 let provenance = GraphProvenance {
50 origin,
51 import_batch_id: batch_id.as_str().to_owned(),
52 source_uri: Some(source_uri(id)),
53 };
54 docs.push(IngestDocument::new(content.clone(), ctx, provenance));
55 }
56 docs
57}
58
59pub trait IngestSourceAdapter: sealed::Sealed + Send + Sync {
71 fn parse(
89 &self,
90 raw: &str,
91 batch_id: &ImportBatchId,
92 ) -> Result<Vec<IngestDocument>, MemoryError>;
93}
94
95#[derive(Debug, Deserialize)]
103pub struct TranscriptEntry {
104 pub seq: u64,
106 pub timestamp: Option<String>,
108 pub message: Message,
110}
111
112pub struct SubagentJsonl {
130 task_id: String,
131 context_window: usize,
133}
134
135impl SubagentJsonl {
136 #[must_use]
143 pub fn new(task_id: impl Into<String>) -> Self {
144 Self {
145 task_id: task_id.into(),
146 context_window: 3,
147 }
148 }
149
150 #[must_use]
152 pub fn with_context_window(mut self, n: usize) -> Self {
153 self.context_window = n;
154 self
155 }
156}
157
158impl sealed::Sealed for SubagentJsonl {}
159
160impl IngestSourceAdapter for SubagentJsonl {
161 fn parse(
166 &self,
167 raw: &str,
168 batch_id: &ImportBatchId,
169 ) -> Result<Vec<IngestDocument>, MemoryError> {
170 let entries: Vec<TranscriptEntry> = raw
171 .lines()
172 .filter(|l| !l.trim().is_empty())
173 .filter_map(|line| match serde_json::from_str::<TranscriptEntry>(line) {
174 Ok(e) => Some(e),
175 Err(err) => {
176 tracing::warn!("SubagentJsonl: skipping malformed line: {err}");
177 None
178 }
179 })
180 .collect();
181
182 let texts: Vec<String> = entries
183 .iter()
184 .map(|e| e.message.to_llm_content().to_owned())
185 .collect();
186 let ids: Vec<String> = entries.iter().map(|e| e.seq.to_string()).collect();
187
188 Ok(build_ingest_documents(
189 &texts,
190 &ids,
191 self.context_window,
192 batch_id,
193 IngestSourceKind::SubagentTranscript.graph_origin(),
194 |id| format!("subagent:{}#{id}", self.task_id),
195 ))
196 }
197}
198
199#[derive(Debug, Deserialize)]
207struct ClaudeCodeRecord {
208 #[serde(rename = "type")]
209 record_type: String,
210 #[serde(default)]
211 uuid: Option<String>,
212 message: Option<ClaudeCodeMessage>,
213}
214
215#[derive(Debug, Deserialize)]
217struct ClaudeCodeMessage {
218 role: String,
219 content: ClaudeCodeContent,
220}
221
222#[derive(Debug, Deserialize)]
224#[serde(untagged)]
225enum ClaudeCodeContent {
226 Text(String),
227 Blocks(Vec<ClaudeCodeBlock>),
228}
229
230#[derive(Debug, Deserialize)]
232struct ClaudeCodeBlock {
233 #[serde(rename = "type")]
234 block_type: String,
235 #[serde(default)]
237 text: Option<String>,
238 #[serde(default)]
240 name: Option<String>,
241}
242
243impl ClaudeCodeContent {
244 fn to_text(&self) -> String {
250 match self {
251 Self::Text(s) => s.clone(),
252 Self::Blocks(blocks) => blocks
253 .iter()
254 .filter_map(|b| match b.block_type.as_str() {
255 "text" | "thinking" => b.text.as_deref().map(str::to_owned),
256 "tool_use" => Some(format!(
257 "<tool: {}>",
258 b.name.as_deref().unwrap_or("unknown")
259 )),
260 _ => None,
261 })
262 .collect::<Vec<_>>()
263 .join("\n"),
264 }
265 }
266}
267
268pub struct ClaudeCodeJsonl {
290 session_id: String,
291 context_window: usize,
292}
293
294impl ClaudeCodeJsonl {
295 #[must_use]
302 pub fn new(session_id: impl Into<String>) -> Self {
303 Self {
304 session_id: session_id.into(),
305 context_window: 3,
306 }
307 }
308
309 #[must_use]
311 pub fn with_context_window(mut self, n: usize) -> Self {
312 self.context_window = n;
313 self
314 }
315}
316
317impl sealed::Sealed for ClaudeCodeJsonl {}
318
319impl IngestSourceAdapter for ClaudeCodeJsonl {
320 fn parse(
325 &self,
326 raw: &str,
327 batch_id: &ImportBatchId,
328 ) -> Result<Vec<IngestDocument>, MemoryError> {
329 let mut texts: Vec<String> = Vec::new();
330 let mut uuids: Vec<String> = Vec::new();
331
332 for (lineno, line) in raw.lines().enumerate() {
333 if line.trim().is_empty() {
334 continue;
335 }
336
337 let record: ClaudeCodeRecord = serde_json::from_str(line).map_err(|e| {
338 MemoryError::Ingest(format!(
339 "ClaudeCodeJsonl: line {lineno} JSON parse error: {e}"
340 ))
341 })?;
342
343 if record.record_type != "user" && record.record_type != "assistant" {
345 continue;
346 }
347
348 let msg = record.message.ok_or_else(|| {
350 MemoryError::Ingest(format!(
351 "ClaudeCodeJsonl: line {lineno} has type={:?} but missing `message` field \
352 — unexpected schema version",
353 record.record_type
354 ))
355 })?;
356
357 if msg.role != "user" && msg.role != "assistant" {
359 return Err(MemoryError::Ingest(format!(
360 "ClaudeCodeJsonl: line {lineno} unexpected message.role={:?}",
361 msg.role
362 )));
363 }
364
365 texts.push(msg.content.to_text());
366 uuids.push(record.uuid.unwrap_or_else(|| format!("line{lineno}")));
367 }
368
369 Ok(build_ingest_documents(
370 &texts,
371 &uuids,
372 self.context_window,
373 batch_id,
374 IngestSourceKind::ExternalAgent.graph_origin(),
375 |uuid| format!("claude-code:{}#{uuid}", self.session_id),
376 ))
377 }
378}
379
380#[derive(Debug, Deserialize)]
389struct CodexRecord {
390 #[serde(rename = "type")]
391 record_type: String,
392 #[serde(default)]
393 payload: Option<CodexPayload>,
394}
395
396#[derive(Debug, Deserialize)]
398struct CodexPayload {
399 #[serde(rename = "type")]
400 payload_type: Option<String>,
401 role: Option<String>,
402 content: Option<Vec<CodexContentBlock>>,
403 id: Option<String>,
404}
405
406#[derive(Debug, Deserialize)]
408struct CodexContentBlock {
409 #[serde(rename = "type")]
410 block_type: String,
411 text: Option<String>,
412}
413
414pub struct CodexJsonl {
439 session_id: String,
440 context_window: usize,
441}
442
443impl CodexJsonl {
444 #[must_use]
449 pub fn new(session_id: impl Into<String>) -> Self {
450 Self {
451 session_id: session_id.into(),
452 context_window: 3,
453 }
454 }
455
456 #[must_use]
458 pub fn with_context_window(mut self, n: usize) -> Self {
459 self.context_window = n;
460 self
461 }
462}
463
464impl sealed::Sealed for CodexJsonl {}
465
466impl IngestSourceAdapter for CodexJsonl {
467 fn parse(
473 &self,
474 raw: &str,
475 batch_id: &ImportBatchId,
476 ) -> Result<Vec<IngestDocument>, MemoryError> {
477 let mut texts: Vec<String> = Vec::new();
478 let mut item_ids: Vec<String> = Vec::new();
479
480 for (lineno, line) in raw.lines().enumerate() {
481 if line.trim().is_empty() {
482 continue;
483 }
484
485 let record: CodexRecord = serde_json::from_str(line).map_err(|e| {
486 MemoryError::Ingest(format!("CodexJsonl: line {lineno} JSON parse error: {e}"))
487 })?;
488
489 if record.record_type != "response_item" {
491 continue;
492 }
493
494 let payload = record.payload.ok_or_else(|| {
496 MemoryError::Ingest(format!(
497 "CodexJsonl: line {lineno} has type=response_item but missing `payload` \
498 — unexpected schema version"
499 ))
500 })?;
501
502 let payload_type = payload.payload_type.as_deref().unwrap_or("");
504 if payload_type != "message" {
505 continue; }
507
508 let role = payload.role.as_deref().unwrap_or("");
509 match role {
510 "user" | "assistant" => {}
511 "developer" => continue, _ => {
513 return Err(MemoryError::Ingest(format!(
514 "CodexJsonl: line {lineno} unexpected payload.role={role:?}"
515 )));
516 }
517 }
518
519 let content_text: String = payload
521 .content
522 .as_deref()
523 .unwrap_or_default()
524 .iter()
525 .filter(|b| b.block_type == "input_text" || b.block_type == "output_text")
526 .filter_map(|b| b.text.as_deref())
527 .collect::<Vec<_>>()
528 .join("\n");
529
530 let item_id = payload.id.unwrap_or_else(|| format!("line{lineno}"));
531
532 texts.push(content_text);
533 item_ids.push(item_id);
534 }
535
536 Ok(build_ingest_documents(
537 &texts,
538 &item_ids,
539 self.context_window,
540 batch_id,
541 IngestSourceKind::ExternalAgent.graph_origin(),
542 |item_id| format!("codex:{}#{item_id}", self.session_id),
543 ))
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::graph::types::GraphOrigin;
551
552 fn make_jsonl(entries: &[(u64, &str)]) -> String {
553 entries
554 .iter()
555 .map(|(seq, text)| {
556 format!(
557 r#"{{"seq":{seq},"timestamp":null,"message":{{"role":"user","content":{text:?},"parts":[]}}}}"#,
558 )
559 })
560 .collect::<Vec<_>>()
561 .join("\n")
562 }
563
564 #[test]
565 fn parse_single_entry() {
566 let raw = make_jsonl(&[(0, "Rust is a systems language")]);
567 let adapter = SubagentJsonl::new("task-1");
568 let batch = ImportBatchId::new();
569 let docs = adapter.parse(&raw, &batch).unwrap();
570 assert_eq!(docs.len(), 1);
571 assert_eq!(docs[0].content(), "Rust is a systems language");
572 assert_eq!(docs[0].source_uri(), "subagent:task-1#0");
573 assert!(!docs[0].content_hash().is_empty());
574 }
575
576 #[test]
577 fn parse_skips_empty_content() {
578 let raw = make_jsonl(&[(0, ""), (1, "non-empty content")]);
579 let adapter = SubagentJsonl::new("task-2");
580 let batch = ImportBatchId::new();
581 let docs = adapter.parse(&raw, &batch).unwrap();
582 assert_eq!(docs.len(), 1);
583 assert_eq!(docs[0].content(), "non-empty content");
584 }
585
586 #[test]
587 fn parse_builds_context_window() {
588 let raw = make_jsonl(&[
589 (0, "first message"),
590 (1, "second message"),
591 (2, "third message"),
592 (3, "fourth message"),
593 ]);
594 let adapter = SubagentJsonl::new("task-3").with_context_window(2);
595 let batch = ImportBatchId::new();
596 let docs = adapter.parse(&raw, &batch).unwrap();
597 assert_eq!(docs.len(), 4);
598 assert!(docs[0].context().is_empty());
599 assert_eq!(docs[1].context().len(), 1);
600 assert_eq!(docs[2].context().len(), 2);
601 assert_eq!(docs[3].context().len(), 2); }
603
604 #[test]
605 fn parse_skips_malformed_lines() {
606 let raw = "not json\n".to_owned() + &make_jsonl(&[(0, "valid message")]);
607 let adapter = SubagentJsonl::new("task-4");
608 let batch = ImportBatchId::new();
609 let docs = adapter.parse(&raw, &batch).unwrap();
610 assert_eq!(docs.len(), 1);
611 }
612
613 #[test]
614 fn parse_embeds_batch_id_in_provenance() {
615 let raw = make_jsonl(&[(0, "content")]);
616 let adapter = SubagentJsonl::new("task-5");
617 let batch = ImportBatchId::new();
618 let docs = adapter.parse(&raw, &batch).unwrap();
619 assert_eq!(docs[0].provenance().import_batch_id, batch.as_str());
620 }
621
622 #[test]
623 fn parse_empty_raw_returns_empty_vec() {
624 let adapter = SubagentJsonl::new("task-6");
625 let batch = ImportBatchId::new();
626 let docs = adapter.parse("", &batch).unwrap();
627 assert!(docs.is_empty());
628 }
629
630 fn make_claude_code_user(uuid: &str, text: &str) -> String {
633 format!(
634 r#"{{"type":"user","uuid":"{uuid}","message":{{"role":"user","content":{text:?}}}}}"#
635 )
636 }
637
638 fn make_claude_code_assistant(uuid: &str, text: &str) -> String {
639 format!(
640 r#"{{"type":"assistant","uuid":"{uuid}","message":{{"role":"assistant","content":[{{"type":"text","text":{text:?}}}]}}}}"#
641 )
642 }
643
644 fn make_claude_code_non_conversation(record_type: &str) -> String {
645 format!(r#"{{"type":"{record_type}","sessionId":"s1"}}"#)
646 }
647
648 #[test]
649 fn claude_code_parses_user_message() {
650 let raw = make_claude_code_user("uuid-1", "Hello from user");
651 let adapter = ClaudeCodeJsonl::new("sess-1");
652 let batch = ImportBatchId::new();
653 let docs = adapter.parse(&raw, &batch).unwrap();
654 assert_eq!(docs.len(), 1);
655 assert_eq!(docs[0].content(), "Hello from user");
656 assert_eq!(docs[0].source_uri(), "claude-code:sess-1#uuid-1");
657 assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
658 }
659
660 #[test]
661 fn claude_code_parses_assistant_array_content() {
662 let raw = make_claude_code_assistant("uuid-2", "Hello from assistant");
663 let adapter = ClaudeCodeJsonl::new("sess-2");
664 let batch = ImportBatchId::new();
665 let docs = adapter.parse(&raw, &batch).unwrap();
666 assert_eq!(docs.len(), 1);
667 assert_eq!(docs[0].content(), "Hello from assistant");
668 }
669
670 #[test]
671 fn claude_code_skips_non_conversation_records() {
672 let raw = [
673 make_claude_code_non_conversation("mode"),
674 make_claude_code_non_conversation("permission-mode"),
675 make_claude_code_non_conversation("file-history-snapshot"),
676 make_claude_code_user("uuid-3", "actual content"),
677 ]
678 .join("\n");
679 let adapter = ClaudeCodeJsonl::new("sess-3");
680 let batch = ImportBatchId::new();
681 let docs = adapter.parse(&raw, &batch).unwrap();
682 assert_eq!(docs.len(), 1);
683 assert_eq!(docs[0].content(), "actual content");
684 }
685
686 #[test]
687 fn claude_code_errors_on_missing_message_in_user_record() {
688 let raw = r#"{"type":"user","uuid":"u1"}"#; let adapter = ClaudeCodeJsonl::new("sess-err");
690 let batch = ImportBatchId::new();
691 assert!(adapter.parse(raw, &batch).is_err());
692 }
693
694 #[test]
695 fn claude_code_skips_empty_content() {
696 let raw = [
697 make_claude_code_user("u1", ""),
698 make_claude_code_user("u2", "non-empty"),
699 ]
700 .join("\n");
701 let adapter = ClaudeCodeJsonl::new("sess-4");
702 let batch = ImportBatchId::new();
703 let docs = adapter.parse(&raw, &batch).unwrap();
704 assert_eq!(docs.len(), 1);
705 assert_eq!(docs[0].content(), "non-empty");
706 }
707
708 #[test]
709 fn claude_code_tags_external_agent_origin() {
710 let raw = make_claude_code_user("u1", "some content");
711 let adapter = ClaudeCodeJsonl::new("sess-5");
712 let batch = ImportBatchId::new();
713 let docs = adapter.parse(&raw, &batch).unwrap();
714 assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
715 }
716
717 #[test]
718 fn claude_code_embeds_batch_id_in_provenance() {
719 let raw = make_claude_code_user("u1", "content");
720 let adapter = ClaudeCodeJsonl::new("sess-6");
721 let batch = ImportBatchId::new();
722 let docs = adapter.parse(&raw, &batch).unwrap();
723 assert_eq!(docs[0].provenance().import_batch_id, batch.as_str());
724 }
725
726 #[test]
727 fn claude_code_context_window() {
728 let raw = [
729 make_claude_code_user("u1", "first"),
730 make_claude_code_user("u2", "second"),
731 make_claude_code_user("u3", "third"),
732 make_claude_code_user("u4", "fourth"),
733 ]
734 .join("\n");
735 let adapter = ClaudeCodeJsonl::new("sess-7").with_context_window(2);
736 let batch = ImportBatchId::new();
737 let docs = adapter.parse(&raw, &batch).unwrap();
738 assert_eq!(docs.len(), 4);
739 assert!(docs[0].context().is_empty());
740 assert_eq!(docs[1].context().len(), 1);
741 assert_eq!(docs[2].context().len(), 2);
742 assert_eq!(docs[3].context().len(), 2); }
744
745 #[test]
746 fn claude_code_thinking_block_extracted() {
747 let raw = r#"{"type":"assistant","uuid":"u1","message":{"role":"assistant","content":[{"type":"thinking","text":"inner reasoning"}]}}"#;
749 let adapter = ClaudeCodeJsonl::new("sess-think");
750 let batch = ImportBatchId::new();
751 let docs = adapter.parse(raw, &batch).unwrap();
752 assert_eq!(docs.len(), 1);
753 assert_eq!(docs[0].content(), "inner reasoning");
754 }
755
756 #[test]
757 fn claude_code_tool_use_block_produces_marker() {
758 let raw = r#"{"type":"assistant","uuid":"u1","message":{"role":"assistant","content":[{"type":"tool_use","name":"shell","text":null}]}}"#;
759 let adapter = ClaudeCodeJsonl::new("sess-tool");
760 let batch = ImportBatchId::new();
761 let docs = adapter.parse(raw, &batch).unwrap();
762 assert_eq!(docs.len(), 1);
763 assert!(docs[0].content().contains("<tool: shell>"));
764 }
765
766 #[test]
767 fn claude_code_empty_raw() {
768 let adapter = ClaudeCodeJsonl::new("sess-empty");
769 let batch = ImportBatchId::new();
770 let docs = adapter.parse("", &batch).unwrap();
771 assert!(docs.is_empty());
772 }
773
774 fn make_codex_session_meta(cwd: &str) -> String {
777 format!(
778 r#"{{"type":"session_meta","payload":{{"id":"s1","cwd":"{cwd}","originator":"codex_cli_rs","cli_version":"1.0.0"}}}}"#
779 )
780 }
781
782 fn make_codex_response_item(role: &str, text: &str, id: &str) -> String {
783 format!(
784 r#"{{"type":"response_item","payload":{{"type":"message","role":"{role}","id":"{id}","content":[{{"type":"input_text","text":{text:?}}}]}}}}"#
785 )
786 }
787
788 fn make_codex_assistant(text: &str, id: &str) -> String {
789 format!(
790 r#"{{"type":"response_item","payload":{{"type":"message","role":"assistant","id":"{id}","content":[{{"type":"output_text","text":{text:?}}}]}}}}"#
791 )
792 }
793
794 #[test]
795 fn codex_parses_user_message() {
796 let raw = [
797 make_codex_session_meta("/project"),
798 make_codex_response_item("user", "Hello", "item-1"),
799 ]
800 .join("\n");
801 let adapter = CodexJsonl::new("sess-c1");
802 let batch = ImportBatchId::new();
803 let docs = adapter.parse(&raw, &batch).unwrap();
804 assert_eq!(docs.len(), 1);
805 assert_eq!(docs[0].content(), "Hello");
806 assert_eq!(docs[0].source_uri(), "codex:sess-c1#item-1");
807 assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
808 }
809
810 #[test]
811 fn codex_skips_developer_role() {
812 let raw = [
813 make_codex_session_meta("/project"),
814 make_codex_response_item("developer", "System instructions", "sys-1"),
815 make_codex_response_item("user", "User message", "u-1"),
816 ]
817 .join("\n");
818 let adapter = CodexJsonl::new("sess-c2");
819 let batch = ImportBatchId::new();
820 let docs = adapter.parse(&raw, &batch).unwrap();
821 assert_eq!(docs.len(), 1);
822 assert_eq!(docs[0].content(), "User message");
823 }
824
825 #[test]
826 fn codex_errors_on_missing_payload_in_response_item() {
827 let raw = r#"{"type":"response_item"}"#; let adapter = CodexJsonl::new("sess-c-err");
829 let batch = ImportBatchId::new();
830 assert!(adapter.parse(raw, &batch).is_err());
831 }
832
833 #[test]
834 fn codex_tags_external_agent_origin() {
835 let raw = make_codex_response_item("assistant", "answer", "a-1");
836 let adapter = CodexJsonl::new("sess-c3");
837 let batch = ImportBatchId::new();
838 let docs = adapter.parse(&raw, &batch).unwrap();
839 assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
840 }
841
842 #[test]
843 fn codex_extracts_output_text_from_assistant_turns() {
844 let raw = [
846 make_codex_session_meta("/project"),
847 make_codex_assistant("assistant answer", "a-1"),
848 ]
849 .join("\n");
850 let adapter = CodexJsonl::new("sess-c4");
851 let batch = ImportBatchId::new();
852 let docs = adapter.parse(&raw, &batch).unwrap();
853 assert_eq!(docs.len(), 1);
854 assert_eq!(docs[0].content(), "assistant answer");
855 }
856
857 #[test]
858 fn codex_embeds_batch_id_in_provenance() {
859 let raw = make_codex_response_item("user", "hello", "u-1");
860 let adapter = CodexJsonl::new("sess-c5");
861 let batch = ImportBatchId::new();
862 let docs = adapter.parse(&raw, &batch).unwrap();
863 assert_eq!(docs[0].provenance().import_batch_id, batch.as_str());
864 }
865
866 #[test]
867 fn codex_context_window() {
868 let raw = [
869 make_codex_response_item("user", "msg1", "i1"),
870 make_codex_response_item("user", "msg2", "i2"),
871 make_codex_response_item("user", "msg3", "i3"),
872 make_codex_response_item("user", "msg4", "i4"),
873 ]
874 .join("\n");
875 let adapter = CodexJsonl::new("sess-c6").with_context_window(2);
876 let batch = ImportBatchId::new();
877 let docs = adapter.parse(&raw, &batch).unwrap();
878 assert_eq!(docs.len(), 4);
879 assert!(docs[0].context().is_empty());
880 assert_eq!(docs[1].context().len(), 1);
881 assert_eq!(docs[2].context().len(), 2);
882 assert_eq!(docs[3].context().len(), 2);
883 }
884
885 #[test]
886 fn codex_skips_empty_content() {
887 let raw = [
888 make_codex_response_item("user", "", "empty-1"),
889 make_codex_response_item("user", "non-empty", "u-2"),
890 ]
891 .join("\n");
892 let adapter = CodexJsonl::new("sess-c7");
893 let batch = ImportBatchId::new();
894 let docs = adapter.parse(&raw, &batch).unwrap();
895 assert_eq!(docs.len(), 1);
896 assert_eq!(docs[0].content(), "non-empty");
897 }
898}