Skip to main content

txcript/harness/
claude_code.rs

1//! Claude Code: `~/.claude/projects/<encoded-cwd>/<session>.jsonl`.
2//!
3//! Claude's on-disk payload is already the Anthropic Messages shape, so the
4//! codec is close to the identity — it is the reference every other harness
5//! normalizes toward. Each line is one [`Record`]; user/assistant lines carry
6//! an [`ApiMessage`] whose `content` is a string or an array of Anthropic
7//! blocks. Non-message lines (summaries, titles, snapshots) are preserved
8//! verbatim in [`Record::Other`] so native ↔ disk stays lossless even though
9//! the codec ignores them.
10
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use chrono::{DateTime, SecondsFormat, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use uuid::Uuid;
19
20use crate::common::{Block, ImageSource, Message, Meta, Role, StopReason, Tool, ToolOutput, Usage};
21use crate::error::{Error, Result};
22use crate::harness::jsonl;
23use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript};
24
25/// The Claude Code harness marker.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct ClaudeCode;
28
29impl Harness for ClaudeCode {
30    const NAME: &'static str = "claude_code";
31    type Body = Vec<Record>;
32}
33
34// ── native records ─────────────────────────────────────────────────────
35
36/// One JSONL line. Known line types are typed; anything else is kept whole in
37/// [`Record::Other`] so the file round-trips without loss.
38#[derive(Debug, Clone, PartialEq)]
39pub enum Record {
40    Summary(SummaryLine),
41    User(EntryLine),
42    Assistant(EntryLine),
43    Other(Value),
44}
45
46/// A `user` or `assistant` line: the per-line envelope plus the wrapped
47/// Anthropic message. Unknown envelope keys collect in `extra`.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct EntryLine {
50    #[serde(
51        rename = "parentUuid",
52        default,
53        skip_serializing_if = "Option::is_none"
54    )]
55    pub parent_uuid: Option<String>,
56    pub uuid: String,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub timestamp: Option<String>,
59    #[serde(rename = "sessionId", default, skip_serializing_if = "Option::is_none")]
60    pub session_id: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub cwd: Option<String>,
63    #[serde(rename = "gitBranch", default, skip_serializing_if = "Option::is_none")]
64    pub git_branch: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub version: Option<String>,
67    pub message: ApiMessage,
68    #[serde(flatten)]
69    pub extra: Map<String, Value>,
70}
71
72/// A `summary` line.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct SummaryLine {
75    pub summary: String,
76    #[serde(flatten)]
77    pub extra: Map<String, Value>,
78}
79
80/// The wrapped Anthropic message. `content` is preserved as raw JSON (string or
81/// block array); the codec is what turns it into typed [`Block`]s.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct ApiMessage {
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub role: Option<String>,
86    pub content: Value,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub model: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub stop_reason: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub usage: Option<Value>,
93    #[serde(flatten)]
94    pub extra: Map<String, Value>,
95}
96
97// Classify by type; malformed known records fall back to Other.
98impl From<Value> for Record {
99    fn from(v: Value) -> Self {
100        match v.get("type").and_then(Value::as_str) {
101            Some("summary") => SummaryLine::deserialize(&v)
102                .map(Record::Summary)
103                .unwrap_or(Record::Other(v)),
104            Some("user") => EntryLine::deserialize(&v)
105                .map(Record::User)
106                .unwrap_or(Record::Other(v)),
107            Some("assistant") => EntryLine::deserialize(&v)
108                .map(Record::Assistant)
109                .unwrap_or(Record::Other(v)),
110            _ => Record::Other(v),
111        }
112    }
113}
114
115impl From<Record> for Value {
116    fn from(r: Record) -> Self {
117        fn tagged(line: impl Serialize, ty: &str) -> Value {
118            let mut v = serde_json::to_value(line).unwrap_or(Value::Null);
119            if let Value::Object(obj) = &mut v {
120                obj.insert("type".into(), Value::String(ty.into()));
121            }
122            v
123        }
124        match r {
125            Record::Summary(s) => tagged(s, "summary"),
126            Record::User(e) => tagged(e, "user"),
127            Record::Assistant(e) => tagged(e, "assistant"),
128            Record::Other(v) => v,
129        }
130    }
131}
132
133impl Serialize for Record {
134    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
135        Value::from(self.clone()).serialize(s)
136    }
137}
138
139impl<'de> Deserialize<'de> for Record {
140    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
141        Ok(Record::from(Value::deserialize(d)?))
142    }
143}
144
145// ── codec ──────────────────────────────────────────────────────────────
146
147impl Codec for ClaudeCode {
148    fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
149        let fallback_ts = transcript.meta.timestamp;
150        let mut messages = Vec::with_capacity(transcript.body.len());
151        // The uuid of the slash command still awaiting its output, so the
152        // `local_command` line that follows pairs to the right call.
153        let mut open_command: Option<&str> = None;
154
155        for record in &transcript.body {
156            let (role, entry) = match record {
157                Record::User(e) => (Role::User, e),
158                Record::Assistant(e) => (Role::Assistant, e),
159                // Newer CLI versions write the same command envelopes on
160                // `system` lines, which have no message wrapper at all.
161                Record::Other(value) => {
162                    messages.extend(system_line_message(value, fallback_ts, &mut open_command));
163                    continue;
164                }
165                // Summaries, titles, snapshots carry no conversational turn.
166                Record::Summary(_) => continue,
167            };
168
169            // Older CLI versions write them as the whole body of a `user`
170            // line; either way they are the user driving the harness, not
171            // conversation, so they become a command call rather than text.
172            let content = match envelope_body(&entry.message.content).and_then(parse_envelope) {
173                Some(envelope) => envelope.into_blocks(
174                    &entry.uuid,
175                    entry.parent_uuid.as_deref(),
176                    &mut open_command,
177                ),
178                None => parse_blocks(&entry.message.content),
179            };
180
181            // An entry whose blocks parse to nothing carries no turn either —
182            // a dropped caveat and an empty body are alike here.
183            if !content.is_empty() {
184                messages.push(Message {
185                    role,
186                    content,
187                    timestamp: entry
188                        .timestamp
189                        .as_deref()
190                        .and_then(parse_ts)
191                        .unwrap_or(fallback_ts),
192                    model: entry.message.model.clone(),
193                    stop_reason: entry.message.stop_reason.as_deref().map(parse_stop_reason),
194                    usage: entry.message.usage.as_ref().and_then(parse_usage),
195                });
196            }
197        }
198
199        Ok(Transcript::new(transcript.meta.clone(), messages))
200    }
201
202    fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
203        let meta = &transcript.meta;
204        let session_id = if meta.id.is_empty() {
205            Uuid::new_v4().to_string()
206        } else {
207            meta.id.clone()
208        };
209        let mut records = Vec::with_capacity(transcript.body.len() + 1);
210
211        // Ids of the commands written so far, so their output lands on a
212        // `local_command` line instead of a stray `tool_result`.
213        let mut command_ids = std::collections::HashSet::new();
214        // Every call the transcript makes. A lone result naming none of them
215        // cannot replay as a `tool_result` — the API rejects an id it never
216        // issued — so it takes the `local_command` line too, which preserves
217        // the text and still loads.
218        let called: std::collections::HashSet<&str> = transcript
219            .body
220            .iter()
221            .flat_map(|msg| &msg.content)
222            .filter_map(|block| match block {
223                Block::ToolUse { id, .. } => Some(id.as_str()),
224                _ => None,
225            })
226            .collect();
227        let mut parent_uuid: Option<String> = None;
228        for (i, msg) in transcript.body.iter().enumerate() {
229            let uuid = entry_uuid(&session_id, i);
230
231            // Local-command turns go back as the native envelopes, not as
232            // message blocks: `tool_use` on a user turn is not a shape the
233            // Anthropic API accepts, so a resumed session would fail to load.
234            if msg.role == Role::User
235                && let Some(record) = local_command_record(
236                    msg,
237                    &mut command_ids,
238                    &called,
239                    &uuid,
240                    parent_uuid.as_deref(),
241                    &session_id,
242                    meta,
243                )
244            {
245                records.push(record);
246                parent_uuid = Some(uuid);
247                continue;
248            }
249
250            let api = ApiMessage {
251                role: Some(role_str(msg.role).to_string()),
252                content: serialize_blocks(&msg.content),
253                model: msg.model.clone(),
254                stop_reason: msg.stop_reason.as_ref().map(stop_reason_str),
255                usage: msg.usage.as_ref().map(serialize_usage),
256                extra: Map::new(),
257            };
258            let entry = EntryLine {
259                parent_uuid: parent_uuid.clone(),
260                uuid: uuid.clone(),
261                timestamp: Some(msg.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)),
262                session_id: Some(session_id.clone()),
263                cwd: meta.cwd.clone(),
264                git_branch: meta.git_branch.clone(),
265                version: meta.cli_version.clone(),
266                message: api,
267                extra: Map::new(),
268            };
269            records.push(match msg.role {
270                Role::User => Record::User(entry),
271                Role::Assistant => Record::Assistant(entry),
272            });
273            parent_uuid = Some(uuid);
274        }
275
276        // A leading summary gives `claude --resume` a friendly title — but its
277        // `leafUuid` must name a real user/assistant line in this file. Claude
278        // Code resolves a session by walking to the leaf the summaries point
279        // at; a summary whose leafUuid matches nothing leaves it with no leaf
280        // and the whole session reads as missing ("No conversation found with
281        // session ID"). A `local_command` line is not an eligible leaf either,
282        // so anchor to the last real turn.
283        if let Some(title) = meta.title.as_deref().filter(|t| !t.is_empty())
284            && let Some(leaf) = records.iter().rev().find_map(|record| match record {
285                Record::User(entry) | Record::Assistant(entry) => Some(entry.uuid.clone()),
286                _ => None,
287            })
288        {
289            records.insert(
290                0,
291                Record::Summary(SummaryLine {
292                    summary: title.to_string(),
293                    extra: Map::from_iter([("leafUuid".into(), Value::String(leaf))]),
294                }),
295            );
296        }
297
298        Ok(Transcript::new(meta.clone(), records))
299    }
300}
301
302impl TextCodec for ClaudeCode {
303    fn from_text(text: &str) -> Result<Transcript<Self>> {
304        // Not jsonl::parse: that would route every line through Record's
305        // Deserialize and its intermediate `Value` tree. Typed lines here
306        // parse straight into their structs.
307        let records: Vec<Record> = text
308            .lines()
309            .filter(|line| !line.trim().is_empty())
310            .filter_map(record_from_line)
311            .collect();
312        let meta = meta_from_records(&records);
313        Ok(Transcript::new(meta, records))
314    }
315
316    fn to_text(transcript: &Transcript<Self>) -> Result<String> {
317        jsonl::render(&transcript.body)
318    }
319}
320
321// ── store ──────────────────────────────────────────────────────────────
322
323/// Reads and writes Claude Code sessions under a projects root (default
324/// `~/.claude/projects`).
325#[derive(Debug, Clone)]
326pub struct ClaudeStore {
327    pub root: PathBuf,
328}
329
330impl ClaudeStore {
331    pub fn new(root: impl Into<PathBuf>) -> Self {
332        Self { root: root.into() }
333    }
334
335    /// The default projects root: `$CLAUDE_CONFIG_DIR/projects` when set
336    /// (every `~/.claude` path moves under that directory), else
337    /// `~/.claude/projects`.
338    #[must_use]
339    pub fn default_root() -> Option<Self> {
340        std::env::var_os("CLAUDE_CONFIG_DIR")
341            .filter(|v| !v.is_empty())
342            .map(|dir| Self::new(PathBuf::from(dir).join("projects")))
343            .or_else(|| dirs_home().map(|h| Self::new(h.join(".claude").join("projects"))))
344    }
345
346    fn collect_jsonl(dir: &Path, out: &mut Vec<PathBuf>) {
347        // An unreadable directory lists nothing.
348        for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
349            let path = entry.path();
350            // `file_type` doesn't follow symlinks: a link pointing back at an
351            // ancestor would otherwise recurse forever. Symlinked directories
352            // are skipped; symlinked session files still list.
353            if entry.file_type().is_ok_and(|t| t.is_dir()) {
354                let name = entry.file_name();
355                let name = name.to_string_lossy();
356                // Subagent and tool-result side-files aren't top-level sessions.
357                if name != "subagents" && name != "tool-results" {
358                    Self::collect_jsonl(&path, out);
359                }
360            } else if path.extension().is_some_and(|e| e == "jsonl") {
361                out.push(path);
362            }
363        }
364    }
365}
366
367impl Store for ClaudeStore {
368    type H = ClaudeCode;
369    type Ref = PathBuf;
370
371    fn discover(&self) -> Result<Vec<Discovered<PathBuf>>> {
372        if self.root.is_dir() {
373            let mut files = Vec::new();
374            Self::collect_jsonl(&self.root, &mut files);
375            Ok(files
376                .into_iter()
377                .filter_map(|path| {
378                    // A session that fails to read is skipped, not fatal. Meta
379                    // comes from the shallow scan, not a full parse.
380                    fs::read_to_string(&path).ok().map(|text| {
381                        let mut meta = meta_from_text(&text);
382                        if meta.id.is_empty() {
383                            meta.id = jsonl::file_id(&path);
384                        }
385                        Discovered {
386                            meta,
387                            reference: path,
388                        }
389                    })
390                })
391                .collect())
392        } else {
393            // A missing root means no sessions, not an error.
394            Ok(Vec::new())
395        }
396    }
397
398    fn load(&self, reference: &PathBuf) -> Result<Transcript<ClaudeCode>> {
399        let mut transcript = ClaudeCode::from_text(&fs::read_to_string(reference)?)?;
400        if transcript.meta.id.is_empty() {
401            transcript.meta.id = jsonl::file_id(reference);
402        }
403        Ok(transcript)
404    }
405
406    fn save(&self, transcript: &Transcript<ClaudeCode>) -> Result<Saved<PathBuf>> {
407        let id = transcript.meta.id.clone();
408        super::checked_id_component(ClaudeCode::NAME, &id)?;
409        let cwd = transcript.meta.cwd.as_deref().unwrap_or_default();
410        let dir = self.root.join(encode_project_dir(cwd));
411        fs::create_dir_all(&dir)?;
412        let path = dir.join(format!("{id}.jsonl"));
413        fs::write(&path, ClaudeCode::to_text(transcript)?)?;
414        Ok(Saved {
415            id,
416            reference: path,
417        })
418    }
419
420    fn delete(&self, reference: &PathBuf) -> Result<()> {
421        Ok(fs::remove_file(reference)?)
422    }
423
424    fn fingerprints(&self, refs: &[PathBuf]) -> Result<HashMap<String, String>> {
425        let mut out = HashMap::with_capacity(refs.len());
426        for path in refs {
427            out.insert(path.to_string_lossy().into_owned(), file_fingerprint(path));
428        }
429        Ok(out)
430    }
431}
432
433// ── local command envelopes ────────────────────────────────────────────
434//
435// Claude Code records the user driving the *harness* — not the model — as
436// XML-ish tags filling a message body. Which line type carries them has
437// changed across CLI versions (a `user` line on older ones, a `system` line
438// with `subtype: "local_command"` on newer), which is why the same event
439// used to arrive as either verbatim markup or nothing at all.
440
441/// One recognized envelope: a slash command the user ran, the output it
442/// printed, or the boilerplate caveat that precedes local-command output.
443enum Envelope {
444    Command {
445        command: String,
446        args: Option<String>,
447    },
448    Stdout(String),
449    /// Fixed generated text carrying no session content — Claude Code emits
450    /// it again on its own, so it does not survive into the canonical model.
451    Caveat,
452}
453
454impl Envelope {
455    /// The canonical blocks for this envelope, advancing the command/output
456    /// pairing cursor. `uuid` identifies the record, `parent` is its
457    /// `parentUuid` — the link Claude Code itself draws from output back to
458    /// the command that produced it.
459    fn into_blocks<'a>(
460        self,
461        uuid: &'a str,
462        parent: Option<&str>,
463        open_command: &mut Option<&'a str>,
464    ) -> Vec<Block> {
465        match self {
466            Envelope::Command { command, args } => {
467                *open_command = Some(uuid);
468                vec![Block::ToolUse {
469                    id: uuid.to_string(),
470                    tool: Tool::Command { command, args },
471                }]
472            }
473            Envelope::Stdout(text) => {
474                // Output whose parent isn't the open command (a `!`-run, or a
475                // command whose own line was never written) stands alone
476                // under its own id rather than attaching to the wrong call.
477                let paired = parent.filter(|p| Some(*p) == *open_command);
478                *open_command = None;
479                vec![Block::ToolResult {
480                    tool_use_id: paired.unwrap_or(uuid).to_string(),
481                    content: ToolOutput::Text(text),
482                    is_error: false,
483                }]
484            }
485            Envelope::Caveat => Vec::new(),
486        }
487    }
488}
489
490/// The text of a body that could be an envelope: a bare string, or a lone
491/// text block. Claude Code has written these lines both ways. Anything else —
492/// notably a `tool_result` whose payload merely quotes the markup — is not a
493/// candidate.
494fn envelope_body(content: &Value) -> Option<&str> {
495    match content {
496        Value::String(text) => Some(text),
497        Value::Array(blocks) => match blocks.as_slice() {
498            [block] if block.get("type").and_then(Value::as_str) == Some("text") => {
499                block.get("text")?.as_str()
500            }
501            _ => None,
502        },
503        _ => None,
504    }
505}
506
507/// Recognize a message body that is *entirely* local-command markup.
508///
509/// The whole-body requirement is what keeps the parse honest: a message that
510/// merely quotes these tags carries prose around them and stays plain text.
511fn parse_envelope(text: &str) -> Option<Envelope> {
512    let tags = envelope_tags(text)?;
513    let tag = |name: &str| tags.iter().find(|(t, _)| *t == name).map(|(_, body)| *body);
514
515    if let Some(name) = tag("command-name") {
516        // `<command-args>` is absent on plenty of versions, and
517        // `<command-message>` only repeats the name without its slash.
518        if !tags
519            .iter()
520            .all(|(t, _)| matches!(*t, "command-name" | "command-message" | "command-args"))
521        {
522            return None;
523        }
524        let name = unescape_envelope(name.trim());
525        if name.is_empty() {
526            return None;
527        }
528        return Some(Envelope::Command {
529            // Canonical form keeps the leading slash whether or not the
530            // recorded name had one.
531            command: if name.starts_with('/') {
532                name
533            } else {
534                format!("/{name}")
535            },
536            args: tag("command-args")
537                .map(str::trim)
538                .filter(|args| !args.is_empty())
539                .map(unescape_envelope),
540        });
541    }
542
543    match tags.as_slice() {
544        [("local-command-stdout", body)] => {
545            Some(Envelope::Stdout(strip_ansi(&unescape_envelope(body))))
546        }
547        [("local-command-caveat", _)] => Some(Envelope::Caveat),
548        // Any other markup is content, not an envelope.
549        _ => None,
550    }
551}
552
553/// Every tag name [`parse_envelope`] recognizes — the close tags whose
554/// literal appearance inside a payload would end its section early.
555const ENVELOPE_TAGS: &[&str] = &[
556    "command-name",
557    "command-message",
558    "command-args",
559    "local-command-stdout",
560    "local-command-caveat",
561];
562
563/// The envelope markup has no quoting of its own: payload text containing a
564/// close tag would truncate its section on re-read, dropping output or
565/// letting content forge a different envelope. Writing therefore adds one
566/// backslash to every `<`…`/tag>` run (`</tag>` → `<\/tag>`, an existing
567/// `<\/tag>` → `<\\/tag>`) and parsing removes one — a bijective pair, so
568/// payloads round-trip losslessly whatever they contain.
569fn escape_envelope(text: &str) -> String {
570    shift_envelope_escapes(text, true)
571}
572
573/// Inverse of [`escape_envelope`]; see there.
574fn unescape_envelope(text: &str) -> String {
575    shift_envelope_escapes(text, false)
576}
577
578fn shift_envelope_escapes(text: &str, add: bool) -> String {
579    let mut out = String::with_capacity(text.len());
580    let mut rest = text;
581    while let Some(pos) = rest.find('<') {
582        out.push_str(&rest[..=pos]);
583        rest = &rest[pos + 1..];
584        let backslashes = rest.len() - rest.trim_start_matches('\\').len();
585        let after = &rest[backslashes..];
586        let close_len = ENVELOPE_TAGS.iter().find_map(|tag| {
587            after
588                .strip_prefix('/')
589                .and_then(|r| r.strip_prefix(*tag))
590                .and_then(|r| r.strip_prefix('>'))
591                .map(|_| tag.len() + 2)
592        });
593        if let Some(close_len) = close_len {
594            let shifted = if add {
595                backslashes + 1
596            } else {
597                backslashes.saturating_sub(1)
598            };
599            for _ in 0..shifted {
600                out.push('\\');
601            }
602            out.push_str(&after[..close_len]);
603            rest = &after[close_len..];
604        }
605        // Otherwise nothing is consumed past the `<`: the run (backslashes
606        // included) flows into `out` with the next chunk.
607    }
608    out.push_str(rest);
609    out
610}
611
612/// Split a body into its `<tag>…</tag>` sections, or `None` if anything but
613/// whitespace sits outside them. Tag order and indentation vary by CLI
614/// version, so the split is order-free.
615fn envelope_tags(text: &str) -> Option<Vec<(&str, &str)>> {
616    let mut tags = Vec::new();
617    let mut rest = text.trim();
618    while !rest.is_empty() {
619        let open_end = rest.strip_prefix('<')?.find('>')? + 1;
620        let name = &rest[1..open_end];
621        if name.is_empty() || !name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-') {
622            return None;
623        }
624        let body_start = open_end + 1;
625        let close = format!("</{name}>");
626        let body_len = rest[body_start..].find(&close)?;
627        tags.push((name, &rest[body_start..body_start + body_len]));
628        rest = rest[body_start + body_len + close.len()..].trim_start();
629    }
630    (!tags.is_empty()).then_some(tags)
631}
632
633/// A `system` line's contribution to the conversation: the local-command
634/// envelopes and nothing else. Every other `system` subtype (turn timings,
635/// away summaries, hook notices) is bookkeeping.
636fn system_line_message<'a>(
637    value: &'a Value,
638    fallback_ts: DateTime<Utc>,
639    open_command: &mut Option<&'a str>,
640) -> Option<Message> {
641    if value.get("type").and_then(Value::as_str) != Some("system") {
642        return None;
643    }
644    let uuid = value.get("uuid").and_then(Value::as_str)?;
645    let envelope = parse_envelope(value.get("content")?.as_str()?)?;
646    let parent = value.get("parentUuid").and_then(Value::as_str);
647    let content = envelope.into_blocks(uuid, parent, open_command);
648    (!content.is_empty()).then(|| Message {
649        role: Role::User,
650        content,
651        timestamp: value
652            .get("timestamp")
653            .and_then(Value::as_str)
654            .and_then(parse_ts)
655            .unwrap_or(fallback_ts),
656        model: None,
657        stop_reason: None,
658        usage: None,
659    })
660}
661
662/// The native record for a user turn that is exactly one local-command
663/// block, or `None` when the turn is ordinary conversation.
664fn local_command_record<'a>(
665    msg: &'a Message,
666    command_ids: &mut std::collections::HashSet<&'a str>,
667    called: &std::collections::HashSet<&str>,
668    uuid: &str,
669    parent_uuid: Option<&str>,
670    session_id: &str,
671    meta: &Meta,
672) -> Option<Record> {
673    let timestamp = msg.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true);
674    match msg.content.as_slice() {
675        [
676            Block::ToolUse {
677                id,
678                tool: Tool::Command { command, args },
679            },
680        ] => {
681            command_ids.insert(id.as_str());
682            let name = escape_envelope(command);
683            let message = escape_envelope(command.trim_start_matches('/'));
684            let body = match args {
685                Some(args) => format!(
686                    "<command-name>{name}</command-name>\n\
687                     <command-message>{message}</command-message>\n\
688                     <command-args>{args}</command-args>",
689                    args = escape_envelope(args),
690                ),
691                None => format!(
692                    "<command-name>{name}</command-name>\n\
693                     <command-message>{message}</command-message>"
694                ),
695            };
696            Some(Record::User(EntryLine {
697                parent_uuid: parent_uuid.map(String::from),
698                uuid: uuid.to_string(),
699                timestamp: Some(timestamp),
700                session_id: Some(session_id.to_string()),
701                cwd: meta.cwd.clone(),
702                git_branch: meta.git_branch.clone(),
703                version: meta.cli_version.clone(),
704                message: ApiMessage {
705                    role: Some("user".to_string()),
706                    content: Value::String(body),
707                    model: None,
708                    stop_reason: None,
709                    usage: None,
710                    extra: Map::new(),
711                },
712                extra: Map::new(),
713            }))
714        }
715        [
716            Block::ToolResult {
717                tool_use_id,
718                content,
719                ..
720            },
721        ] if command_ids.contains(tool_use_id.as_str())
722            || !called.contains(tool_use_id.as_str()) =>
723        {
724            let text = match content {
725                ToolOutput::Text(text) => text.clone(),
726                ToolOutput::Json(value) => value.to_string(),
727            };
728            let mut line = Map::new();
729            let mut put = |key: &str, value: Value| {
730                line.insert(key.to_string(), value);
731            };
732            put("type", Value::String("system".into()));
733            put("subtype", Value::String("local_command".into()));
734            put(
735                "content",
736                Value::String(format!(
737                    "<local-command-stdout>{}</local-command-stdout>",
738                    escape_envelope(&text)
739                )),
740            );
741            put("level", Value::String("info".into()));
742            put("isMeta", Value::Bool(false));
743            put("uuid", Value::String(uuid.to_string()));
744            put("timestamp", Value::String(timestamp));
745            put("sessionId", Value::String(session_id.to_string()));
746            for (key, value) in [
747                ("parentUuid", parent_uuid.map(String::from)),
748                ("cwd", meta.cwd.clone()),
749                ("gitBranch", meta.git_branch.clone()),
750                ("version", meta.cli_version.clone()),
751            ] {
752                if let Some(value) = value {
753                    put(key, Value::String(value));
754                }
755            }
756            Some(Record::Other(Value::Object(line)))
757        }
758        // Anything else is an ordinary turn.
759        _ => None,
760    }
761}
762
763/// Strip ANSI escapes from local command output: `/context` and friends draw
764/// in colour, which is noise both in the text projection and in the search
765/// index.
766fn strip_ansi(text: &str) -> String {
767    let mut out = String::with_capacity(text.len());
768    let mut chars = text.chars();
769    while let Some(c) = chars.next() {
770        if c != '\u{1b}' {
771            out.push(c);
772            continue;
773        }
774        match chars.next() {
775            // CSI: parameter and intermediate bytes, then a final byte.
776            Some('[') => {
777                for c in chars.by_ref() {
778                    if matches!(c, '\u{40}'..='\u{7e}') {
779                        break;
780                    }
781                }
782            }
783            // OSC: runs to BEL or to the string terminator `ESC \`.
784            Some(']') => {
785                let mut escaped = false;
786                for c in chars.by_ref() {
787                    if c == '\u{7}' || (escaped && c == '\\') {
788                        break;
789                    }
790                    escaped = c == '\u{1b}';
791                }
792            }
793            // Two-character escapes drop both; a trailing ESC drops itself.
794            Some(_) | None => {}
795        }
796    }
797    out
798}
799
800// ── block <-> value ────────────────────────────────────────────────────
801
802fn parse_blocks(content: &Value) -> Vec<Block> {
803    match content {
804        Value::String(s) => {
805            if s.is_empty() {
806                Vec::new()
807            } else {
808                vec![Block::Text { text: s.clone() }]
809            }
810        }
811        Value::Array(arr) => arr.iter().filter_map(parse_block).collect(),
812        // Content is a string or a block array on the wire; any other JSON
813        // shape carries no blocks.
814        Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => Vec::new(),
815    }
816}
817
818fn parse_block(v: &Value) -> Option<Block> {
819    match v.get("type").and_then(Value::as_str)? {
820        "text" => Some(Block::Text {
821            text: v.get("text")?.as_str()?.to_string(),
822        }),
823        "thinking" => Some(Block::Thinking {
824            text: v.get("thinking")?.as_str()?.to_string(),
825            signature: v.get("signature").and_then(Value::as_str).map(String::from),
826            encrypted: None,
827        }),
828        "tool_use" => {
829            let id = v.get("id")?.as_str()?.to_string();
830            let name = v.get("name")?.as_str()?;
831            let input = v.get("input").cloned().unwrap_or(Value::Object(Map::new()));
832            Some(Block::ToolUse {
833                id,
834                tool: Tool::from_canonical(name, input),
835            })
836        }
837        "tool_result" => Some(Block::ToolResult {
838            tool_use_id: v.get("tool_use_id")?.as_str()?.to_string(),
839            content: parse_tool_output(v.get("content")),
840            is_error: v.get("is_error").and_then(Value::as_bool).unwrap_or(false),
841        }),
842        "image" => {
843            let source = v.get("source")?;
844            Some(Block::Image {
845                source: ImageSource {
846                    source_type: source
847                        .get("type")
848                        .and_then(Value::as_str)
849                        .unwrap_or("base64")
850                        .to_string(),
851                    media_type: source.get("media_type")?.as_str()?.to_string(),
852                    data: source.get("data")?.as_str()?.to_string(),
853                },
854            })
855        }
856        // Block types we don't model carry nothing into the canonical model
857        // (the native `Record` still round-trips them).
858        _ => None,
859    }
860}
861
862fn serialize_blocks(blocks: &[Block]) -> Value {
863    Value::Array(blocks.iter().map(serialize_block).collect())
864}
865
866fn serialize_block(block: &Block) -> Value {
867    match block {
868        Block::Text { text } => serde_json::json!({"type": "text", "text": text}),
869        Block::Thinking {
870            text, signature, ..
871        } => {
872            let mut obj = serde_json::json!({"type": "thinking", "thinking": text});
873            if let Some(sig) = signature {
874                obj["signature"] = Value::String(sig.clone());
875            }
876            obj
877        }
878        Block::ToolUse { id, tool } => {
879            let (name, input) = tool.to_canonical();
880            serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input})
881        }
882        Block::ToolResult {
883            tool_use_id,
884            content,
885            is_error,
886        } => {
887            let mut obj = serde_json::json!({
888                "type": "tool_result",
889                "tool_use_id": tool_use_id,
890                "content": serialize_tool_output(content),
891            });
892            if *is_error {
893                obj["is_error"] = Value::Bool(true);
894            }
895            obj
896        }
897        Block::Image { source } => serde_json::json!({
898            "type": "image",
899            "source": {
900                "type": source.source_type,
901                "media_type": source.media_type,
902                "data": source.data,
903            },
904        }),
905    }
906}
907
908fn parse_tool_output(content: Option<&Value>) -> ToolOutput {
909    match content {
910        Some(Value::String(s)) => ToolOutput::Text(s.clone()),
911        Some(other) => ToolOutput::Json(other.clone()),
912        None => ToolOutput::Text(String::new()),
913    }
914}
915
916fn serialize_tool_output(out: &ToolOutput) -> Value {
917    match out {
918        ToolOutput::Text(s) => Value::String(s.clone()),
919        // The Anthropic wire accepts only a string or a content-block array
920        // in `tool_result.content`; a bare object fails the whole session
921        // load on resume. Keep block arrays (Claude's own native shape),
922        // flatten any other JSON to its compact text.
923        ToolOutput::Json(v) if is_block_array(v) => v.clone(),
924        ToolOutput::Json(v) => Value::String(v.to_string()),
925    }
926}
927
928/// Whether a value is an Anthropic content-block array — the only non-string
929/// shape Claude Code itself writes into `tool_result.content`.
930fn is_block_array(v: &Value) -> bool {
931    v.as_array().is_some_and(|arr| {
932        arr.iter()
933            .all(|b| b.get("type").and_then(Value::as_str).is_some())
934    })
935}
936
937fn parse_usage(v: &Value) -> Option<Usage> {
938    Some(Usage {
939        input_tokens: v.get("input_tokens")?.as_u64()?,
940        output_tokens: v.get("output_tokens")?.as_u64()?,
941        cache_read_input_tokens: v.get("cache_read_input_tokens").and_then(Value::as_u64),
942        cache_creation_input_tokens: v.get("cache_creation_input_tokens").and_then(Value::as_u64),
943    })
944}
945
946fn serialize_usage(u: &Usage) -> Value {
947    let mut obj = serde_json::json!({
948        "input_tokens": u.input_tokens,
949        "output_tokens": u.output_tokens,
950    });
951    if let Some(read) = u.cache_read_input_tokens {
952        obj["cache_read_input_tokens"] = read.into();
953    }
954    if let Some(write) = u.cache_creation_input_tokens {
955        obj["cache_creation_input_tokens"] = write.into();
956    }
957    obj
958}
959
960// Claude's stop_reason strings are the canonical Anthropic set.
961fn parse_stop_reason(s: &str) -> StopReason {
962    match s {
963        "end_turn" => StopReason::EndTurn,
964        "tool_use" => StopReason::ToolUse,
965        "max_tokens" => StopReason::MaxTokens,
966        "stop_sequence" => StopReason::StopSequence,
967        other => StopReason::Other(other.to_string()),
968    }
969}
970
971fn stop_reason_str(r: &StopReason) -> String {
972    match r {
973        StopReason::EndTurn => "end_turn".into(),
974        StopReason::ToolUse => "tool_use".into(),
975        StopReason::MaxTokens => "max_tokens".into(),
976        StopReason::StopSequence => "stop_sequence".into(),
977        StopReason::Aborted => "aborted".into(),
978        StopReason::Error => "error".into(),
979        StopReason::Other(s) => s.clone(),
980    }
981}
982
983// ── helpers ────────────────────────────────────────────────────────────
984
985fn role_str(role: Role) -> &'static str {
986    match role {
987        Role::User => "user",
988        Role::Assistant => "assistant",
989    }
990}
991
992fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
993    s.parse::<DateTime<Utc>>().ok()
994}
995
996/// Deterministic per-entry uuid, so `from_common` is a pure function of the
997/// transcript (no randomness, reproducible conversions and tests).
998fn entry_uuid(session_id: &str, index: usize) -> String {
999    const NS: Uuid = Uuid::from_bytes([
1000        0x9f, 0x0d, 0x98, 0x36, 0x9e, 0xe7, 0x4c, 0x62, 0x83, 0xb4, 0xfb, 0x8e, 0x01, 0x36, 0x5c,
1001        0x9f,
1002    ]);
1003    Uuid::new_v5(&NS, format!("{session_id}:{index}").as_bytes()).to_string()
1004}
1005
1006/// Extract session metadata from the records. `id` is left empty when no
1007/// `sessionId` is present; a [`Store`] fills it from the filename.
1008fn meta_from_records(records: &[Record]) -> Meta {
1009    let mut meta = Meta {
1010        id: String::new(),
1011        timestamp: Utc::now(),
1012        cwd: None,
1013        git_branch: None,
1014        title: None,
1015        cli_version: None,
1016        model: None,
1017    };
1018    let mut summary: Option<String> = None;
1019    let mut custom_title: Option<String> = None;
1020    let mut earliest: Option<DateTime<Utc>> = None;
1021
1022    for record in records {
1023        match record {
1024            Record::User(e) => {
1025                if let Some(id) = &e.session_id
1026                    && meta.id.is_empty()
1027                {
1028                    meta.id.clone_from(id);
1029                }
1030                meta.cwd = meta.cwd.take().or_else(|| e.cwd.clone());
1031                meta.git_branch = meta.git_branch.take().or_else(|| e.git_branch.clone());
1032                meta.cli_version = meta.cli_version.take().or_else(|| e.version.clone());
1033                note_ts(&mut earliest, e.timestamp.as_deref());
1034            }
1035            Record::Assistant(e) => {
1036                if meta.model.is_none() {
1037                    meta.model.clone_from(&e.message.model);
1038                }
1039                note_ts(&mut earliest, e.timestamp.as_deref());
1040            }
1041            Record::Summary(s) => {
1042                if summary.is_none() {
1043                    summary = Some(s.summary.clone());
1044                }
1045            }
1046            Record::Other(v) => match v.get("type").and_then(Value::as_str) {
1047                Some("custom-title") => {
1048                    custom_title = v
1049                        .get("customTitle")
1050                        .and_then(Value::as_str)
1051                        .map(String::from);
1052                }
1053                Some("agent-name") if custom_title.is_none() => {
1054                    custom_title = v.get("agentName").and_then(Value::as_str).map(String::from);
1055                }
1056                // Other line types carry no session metadata.
1057                _ => {}
1058            },
1059        }
1060    }
1061
1062    if let Some(ts) = earliest {
1063        meta.timestamp = ts;
1064    }
1065    meta.title = custom_title.or(summary);
1066    meta
1067}
1068
1069fn note_ts(earliest: &mut Option<DateTime<Utc>>, ts: Option<&str>) {
1070    if let Some(parsed) = ts.and_then(parse_ts)
1071        && earliest.is_none_or(|e| parsed < e)
1072    {
1073        *earliest = Some(parsed);
1074    }
1075}
1076
1077// ── discover: shallow meta scan ────────────────────────────────────────
1078
1079/// Shallow mirror of [`EntryLine`] for discovery: the same typed fields with
1080/// the same types — so a line that fails the full parse fails here too — but
1081/// the payload-bearing `message.content` is only presence-checked, never
1082/// built. Fields the full parse tolerates unconditionally (`extra`, `usage`)
1083/// are covered by serde's ignore-unknown default.
1084#[derive(Deserialize)]
1085struct MetaEntryLine {
1086    #[serde(rename = "parentUuid", default)]
1087    parent_uuid: Option<String>,
1088    uuid: String,
1089    #[serde(default)]
1090    timestamp: Option<String>,
1091    #[serde(rename = "sessionId", default)]
1092    session_id: Option<String>,
1093    #[serde(default)]
1094    cwd: Option<String>,
1095    #[serde(rename = "gitBranch", default)]
1096    git_branch: Option<String>,
1097    #[serde(default)]
1098    version: Option<String>,
1099    message: MetaApiMessage,
1100}
1101
1102/// Shallow mirror of [`ApiMessage`]: `content` must be present (as in the
1103/// full parse) but its value is skipped, not built.
1104#[derive(Deserialize)]
1105struct MetaApiMessage {
1106    #[serde(default)]
1107    role: Option<String>,
1108    #[allow(dead_code)] // presence check only; the value is never built
1109    content: serde::de::IgnoredAny,
1110    #[serde(default)]
1111    model: Option<String>,
1112    #[serde(default)]
1113    stop_reason: Option<String>,
1114}
1115
1116// A skeleton [`EntryLine`] carrying only what [`meta_from_records`] reads.
1117impl From<MetaEntryLine> for EntryLine {
1118    fn from(m: MetaEntryLine) -> EntryLine {
1119        EntryLine {
1120            parent_uuid: m.parent_uuid,
1121            uuid: m.uuid,
1122            timestamp: m.timestamp,
1123            session_id: m.session_id,
1124            cwd: m.cwd,
1125            git_branch: m.git_branch,
1126            version: m.version,
1127            message: ApiMessage {
1128                role: m.message.role,
1129                content: Value::Null,
1130                model: m.message.model,
1131                stop_reason: m.message.stop_reason,
1132                usage: None,
1133                extra: Map::new(),
1134            },
1135            extra: Map::new(),
1136        }
1137    }
1138}
1139
1140/// Parse one line straight into its typed record — no intermediate [`Value`]
1141/// tree. Only lines outside the typed set pay a second parse into the
1142/// preserved [`Record::Other`] `Value`: an unknown or non-string tag, or the
1143/// rare known-tag line whose body fails its typed schema. Invalid JSON
1144/// parses to `None` and is skipped, exactly as under [`jsonl::parse`].
1145fn record_from_line(line: &str) -> Option<Record> {
1146    let other = || serde_json::from_str::<Value>(line).ok().map(Record::Other);
1147    match serde_json::from_str::<jsonl::TypeProbe>(line) {
1148        // A failed probe isn't necessarily junk: a non-string `type` also
1149        // fails it. The fallback keeps such lines whole and skips the rest.
1150        Err(_) => other(),
1151        Ok(probe) => match probe.kind.as_deref() {
1152            Some("summary") => serde_json::from_str(line)
1153                .ok()
1154                .map(Record::Summary)
1155                .or_else(other),
1156            Some("user") => serde_json::from_str(line)
1157                .ok()
1158                .map(Record::User)
1159                .or_else(other),
1160            Some("assistant") => serde_json::from_str(line)
1161                .ok()
1162                .map(Record::Assistant)
1163                .or_else(other),
1164            Some(_) | None => other(),
1165        },
1166    }
1167}
1168
1169/// Parse one line only as far as discovery needs: user/assistant lines
1170/// through the shallow mirrors, summary/custom-title/agent-name lines whole
1171/// (each is one short line). Everything else — malformed lines included —
1172/// lands in [`Record::Other`] under the full parse and contributes no
1173/// metadata there, so here it parses to `None`.
1174fn scan_line(line: &str) -> Option<Record> {
1175    let probe: jsonl::TypeProbe = serde_json::from_str(line).ok()?;
1176    match probe.kind.as_deref() {
1177        Some("user") => serde_json::from_str::<MetaEntryLine>(line)
1178            .ok()
1179            .map(|m| Record::User(m.into())),
1180        Some("assistant") => serde_json::from_str::<MetaEntryLine>(line)
1181            .ok()
1182            .map(|m| Record::Assistant(m.into())),
1183        Some("summary") => serde_json::from_str::<SummaryLine>(line)
1184            .ok()
1185            .map(Record::Summary),
1186        Some("custom-title" | "agent-name") => serde_json::from_str(line).ok().map(Record::Other),
1187        // No other line type carries session metadata.
1188        Some(_) | None => None,
1189    }
1190}
1191
1192/// [`meta_from_records`] over a shallow scan of the raw text — equivalent to
1193/// `from_text(text)?.meta`, but discovery never builds message payloads.
1194fn meta_from_text(text: &str) -> Meta {
1195    let records: Vec<Record> = text
1196        .lines()
1197        .filter(|line| !line.trim().is_empty())
1198        .filter_map(scan_line)
1199        .collect();
1200    meta_from_records(&records)
1201}
1202
1203/// Claude's project-dir encoding: every `/` and `.` becomes `-`. Windows
1204/// cwds encode the same way with `\` and the drive `:` also mapped
1205/// (`C:\Users\x` → `C--Users-x`).
1206fn encode_project_dir(path: &str) -> String {
1207    path.chars()
1208        .map(|c| {
1209            if matches!(c, '/' | '.' | '\\' | ':') {
1210                '-'
1211            } else {
1212                c
1213            }
1214        })
1215        .collect()
1216}
1217
1218fn file_fingerprint(path: &Path) -> String {
1219    match fs::metadata(path) {
1220        // An unreadable file has no fingerprint.
1221        Err(_) => String::new(),
1222        Ok(meta) => {
1223            let mtime = meta
1224                .modified()
1225                .ok()
1226                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1227                .map_or(0, |d| d.as_nanos());
1228            format!("{mtime}:{}", meta.len())
1229        }
1230    }
1231}
1232
1233fn dirs_home() -> Option<PathBuf> {
1234    super::home_dir()
1235}
1236
1237// Surface the canonical-name guard as a typed error for callers that care.
1238#[allow(dead_code)]
1239fn unconvertible(detail: impl Into<String>) -> Error {
1240    Error::Unconvertible {
1241        harness: ClaudeCode::NAME,
1242        detail: detail.into(),
1243    }
1244}