use std::io::{BufRead, BufReader, Read};
use serde_json::Value;
use crate::error::{MnemoError, Result};
use crate::memory::ThreadTurn;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TranscriptFormat {
JsonlGeneric,
ClaudeCodeJsonl,
}
impl TranscriptFormat {
pub fn parse(s: &str) -> Option<TranscriptFormat> {
match s.to_ascii_lowercase().as_str() {
"jsonl" => Some(TranscriptFormat::JsonlGeneric),
"claude-code-jsonl" | "claude_code_jsonl" => Some(TranscriptFormat::ClaudeCodeJsonl),
_ => None,
}
}
}
pub fn read_transcript<R: Read>(reader: R, format: TranscriptFormat) -> Result<Vec<ThreadTurn>> {
let mut out = Vec::new();
for (lineno, line) in BufReader::new(reader).lines().enumerate() {
let line = line.map_err(MnemoError::Io)?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let v: Value = serde_json::from_str(trimmed).map_err(|e| {
MnemoError::Invalid(format!("transcript line {}: invalid JSON: {e}", lineno + 1))
})?;
let turn = match format {
TranscriptFormat::JsonlGeneric => parse_generic_line(&v)?,
TranscriptFormat::ClaudeCodeJsonl => parse_claude_code_line(&v)?,
};
if let Some(t) = turn {
out.push(t);
}
}
Ok(out)
}
fn parse_generic_line(v: &Value) -> Result<Option<ThreadTurn>> {
let obj = v.as_object().ok_or_else(|| {
MnemoError::Invalid("transcript: generic jsonl line must be a JSON object".into())
})?;
let turn_id = obj
.get("turn_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
MnemoError::Invalid("transcript: generic jsonl line missing 'turn_id' (string)".into())
})?
.to_string();
let role = obj
.get("role")
.and_then(|v| v.as_str())
.ok_or_else(|| {
MnemoError::Invalid("transcript: generic jsonl line missing 'role' (string)".into())
})?
.to_string();
let content = obj
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| {
MnemoError::Invalid("transcript: generic jsonl line missing 'content' (string)".into())
})?
.to_string();
let timestamp = obj.get("timestamp").and_then(|v| v.as_i64());
Ok(Some(ThreadTurn {
turn_id,
role,
content,
timestamp,
}))
}
fn parse_claude_code_line(v: &Value) -> Result<Option<ThreadTurn>> {
let obj = match v.as_object() {
Some(o) => o,
None => return Ok(None),
};
let type_tag = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if type_tag != "user" && type_tag != "assistant" {
return Ok(None);
}
let turn_id = obj
.get("uuid")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
MnemoError::Invalid("transcript: claude-code-jsonl message missing 'uuid'".into())
})?;
let message = obj
.get("message")
.and_then(|v| v.as_object())
.ok_or_else(|| {
MnemoError::Invalid(
"transcript: claude-code-jsonl message missing 'message' object".into(),
)
})?;
let role = message
.get("role")
.and_then(|v| v.as_str())
.unwrap_or(type_tag)
.to_string();
let content = match message.get("content") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => flatten_blocks(blocks),
_ => String::new(),
};
let timestamp = obj
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(parse_iso8601_secs)
.or_else(|| {
message
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(parse_iso8601_secs)
});
Ok(Some(ThreadTurn {
turn_id,
role,
content,
timestamp,
}))
}
fn flatten_blocks(blocks: &[Value]) -> String {
let mut out = String::new();
for b in blocks {
let Some(obj) = b.as_object() else {
continue;
};
let t = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
match t {
"text" => {
if let Some(s) = obj.get("text").and_then(|v| v.as_str()) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(s);
}
}
"tool_use" => {
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("?");
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("[tool_use name={name}]"));
}
"tool_result" => {
let is_error = obj
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let status = if is_error { "error" } else { "ok" };
let inner = obj.get("content").and_then(|c| match c {
Value::String(s) => Some(s.clone()),
Value::Array(nested) => Some(flatten_blocks(nested)),
_ => None,
});
if !out.is_empty() {
out.push('\n');
}
match inner {
Some(body) => out.push_str(&format!("[tool_result {status}] {body}")),
None => out.push_str(&format!("[tool_result {status}]")),
}
}
"image" => {
if !out.is_empty() {
out.push('\n');
}
out.push_str("[image]");
}
other => {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("[{other}]"));
}
}
}
out
}
fn parse_iso8601_secs(s: &str) -> Option<i64> {
let bytes = s.as_bytes();
if bytes.len() < 19 || bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
return None;
}
let year: i64 = s.get(0..4)?.parse().ok()?;
let month: u32 = s.get(5..7)?.parse().ok()?;
let day: u32 = s.get(8..10)?.parse().ok()?;
let hour: u32 = s.get(11..13)?.parse().ok()?;
let minute: u32 = s.get(14..16)?.parse().ok()?;
let second: u32 = s.get(17..19)?.parse().ok()?;
Some(
days_from_civil(year, month, day) * 86_400
+ hour as i64 * 3600
+ minute as i64 * 60
+ second as i64,
)
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as i64 + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jsonl_generic_round_trip() {
let src = r#"
{"turn_id":"a","role":"user","content":"hi","timestamp":1700000000}
{"turn_id":"b","role":"assistant","content":"hello"}
"#;
let turns = read_transcript(src.as_bytes(), TranscriptFormat::JsonlGeneric).unwrap();
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].turn_id, "a");
assert_eq!(turns[0].role, "user");
assert_eq!(turns[0].timestamp, Some(1_700_000_000));
assert_eq!(turns[1].timestamp, None);
}
#[test]
fn claude_code_jsonl_skips_non_message_types() {
let src = r#"{"type":"system","uuid":"sys-1","content":"init"}
{"type":"user","uuid":"u-1","message":{"role":"user","content":"hi"}}
{"type":"assistant","uuid":"a-1","message":{"role":"assistant","content":[{"type":"text","text":"hey"}]}}
"#;
let turns = read_transcript(src.as_bytes(), TranscriptFormat::ClaudeCodeJsonl).unwrap();
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].turn_id, "u-1");
assert_eq!(turns[1].content, "hey");
}
#[test]
fn claude_code_flattens_tool_use_and_tool_result() {
let src = r#"{"type":"assistant","uuid":"a","message":{"role":"assistant","content":[{"type":"text","text":"let me run:"},{"type":"tool_use","id":"t","name":"Bash","input":{}}]}}
{"type":"user","uuid":"u","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t","content":"ok\n","is_error":false}]}}
"#;
let turns = read_transcript(src.as_bytes(), TranscriptFormat::ClaudeCodeJsonl).unwrap();
assert_eq!(turns.len(), 2);
assert!(turns[0].content.contains("let me run:"));
assert!(turns[0].content.contains("[tool_use name=Bash]"));
assert!(turns[1].content.contains("[tool_result ok]"));
}
}