Skip to main content

supercode_interchange/
session.rs

1//! Natively load — and continue — real Claude Code and Codex sessions.
2//!
3//! Both tools persist their conversations as JSONL on disk:
4//!
5//! - **Claude Code**: `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,
6//!   one line per event in the Anthropic message format, linked by
7//!   `uuid`/`parentUuid`.
8//! - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, where each line
9//!   is a `{timestamp, type, payload}` envelope and the `response_item` lines
10//!   form the canonical conversation.
11//!
12//! [`Session::load`] auto-detects the format and normalizes either one into a
13//! provider-neutral [`Vec<ChatMessage>`] that can be handed straight back to a
14//! model (via OpenRouter or any OpenAI-compatible endpoint) to continue.
15//!
16//! Provider-internal artifacts that don't replay across vendors — Anthropic
17//! `thinking` blocks, Codex `reasoning` items — are dropped during
18//! normalization.
19//!
20//! # Where this sits in supercode's priorities
21//!
22//! This module is the home of **feature 1 (translate between session formats)**
23//! and half of **feature 2 (emulate-to-continue)** — the load/emit surface for
24//! each harness ([`SessionFormat`], `from_*_str` loaders, `to_*_jsonl`
25//! emitters). See [`AGENTS.md`](../../../AGENTS.md) for the three ranked
26//! feature-priorities and the glue-tool positioning; the top priority is
27//! **feature 3 (continue losslessly *with massive token reduction*)**, which
28//! this fidelity work exists to make trustworthy. `opencode` + `pi` loaders
29//! are built against the frozen `docs/interop/opencode-pi-spec.md` contract
30//! — OpenCode additionally reads its native SQLite store (`opencode*.db`,
31//! PARITY-3/PARITY-16) via `rusqlite`, reconstructing the same envelope form
32//! [`Session::from_opencode_str`] already parses for the JSON-tree surfaces.
33
34use std::collections::{BTreeMap, HashMap, HashSet};
35use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
36use std::path::{Path, PathBuf};
37
38use rusqlite::Connection;
39use serde_json::Value;
40
41use crate::{
42    ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
43};
44
45/// Which tool produced a session log.
46///
47/// This is **read-provenance**: a fact recovered when a log is loaded (stored
48/// in [`SessionMeta::source`], filled in by auto-detection in
49/// `detect_source`), describing which tool originally wrote the file on
50/// disk. It answers "where did this session come from?" — e.g. for
51/// `inspect`/`convert` display in the CLI.
52///
53/// It is deliberately distinct from [`SessionFormat`], even though the two
54/// enums' variant lists currently coincide: [`SessionFormat`] selects a
55/// serialization codec (what to parse/export *as*), while `SessionSource`
56/// records history (what wrote the file). The pair is intentionally kept
57/// separate rather than merged — a session loaded from one tool's log can
58/// still be exported in the other tool's format, and the two concepts could
59/// diverge further (e.g. a format that is readable but not attributable, or
60/// multiple versioned formats sharing one source).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SessionSource {
63    /// A `~/.claude/projects/.../<id>.jsonl` transcript.
64    ClaudeCode,
65    /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
66    Codex,
67    /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
68    /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
69    /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
70    /// 1:1 per the frozen interop spec (§0).
71    OpenCode,
72    /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
73    /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
74    /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
75    /// round-trip property.
76    Pi,
77    /// A Grok session transcript stored as
78    /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
79    Grok,
80    /// A Gemini CLI transcript stored under
81    /// `~/.gemini/tmp/<project>/chats/session-*.jsonl`.
82    Gemini,
83    /// A Goose session exported through `_goose/unstable/session/export`, or
84    /// reconstructed from Goose's `sessions/sessions.db` native store.
85    Goose,
86    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
87    /// cosmetic"): a session that was never imported from ANY foreign
88    /// tool's log at all — authored directly by supercode's own agent loop,
89    /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
90    /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
91    /// natively-spawned `spawn_subagent` children) uses this — before this
92    /// variant existed, that call site built its blank `Session` via
93    /// `Session::from_claude_code_str("")` purely as an "empty parser to
94    /// get a blank skeleton" trick, which left `meta.source ==
95    /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
96    /// was ever involved, mislabeling a native supercode spawn as an
97    /// imported CC session on disk (and in any `inspect`/`convert` reading
98    /// it back). Never produced by auto-detection (`detect_source`) or any
99    /// `from_<tool>_str` loader — only by code that explicitly constructs
100    /// a `SessionMeta` with this source, so no existing imported-session
101    /// path can ever observe this variant appearing where it didn't before.
102    Native,
103}
104
105/// An on-disk session format supercode can both read and write.
106///
107/// Like an image editor that opens and exports several file formats, supercode
108/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
109/// supported format on the edges.
110///
111/// This is a **write-target** / codec selector: a caller's request, passed to
112/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
113/// choosing which on-disk dialect to parse or emit. It answers "what format
114/// should I read/write?" — as opposed to [`SessionSource`], which records the
115/// provenance fact of what actually produced a loaded file. The two enums are
116/// intentionally kept separate (provenance fact vs. serialization choice) and
117/// should not be unified, even though their variants currently match
118/// one-to-one.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SessionFormat {
121    /// Claude Code transcript JSONL.
122    ClaudeCode,
123    /// Codex rollout JSONL.
124    Codex,
125    /// OpenCode export-document / envelope JSONL (wave B; see
126    /// [`SessionSource::OpenCode`]).
127    OpenCode,
128    /// Pi session JSONL (see [`SessionSource::Pi`]).
129    Pi,
130    /// Grok `chat_history.jsonl` transcript.
131    Grok,
132    /// Gemini CLI session JSONL.
133    Gemini,
134    /// Goose native session-export JSON.
135    Goose,
136}
137
138impl SessionFormat {
139    /// The [`SessionSource`] a file of this format reports.
140    ///
141    /// This is the deliberate one-way bridge between the two concepts: a file
142    /// saved in this format will, when reloaded, report this provenance (see
143    /// `crates/harness/tests/session_saving.rs`), making the relationship
144    /// discoverable from the method itself.
145    pub fn source(self) -> SessionSource {
146        match self {
147            SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
148            SessionFormat::Codex => SessionSource::Codex,
149            SessionFormat::OpenCode => SessionSource::OpenCode,
150            SessionFormat::Pi => SessionSource::Pi,
151            SessionFormat::Grok => SessionSource::Grok,
152            SessionFormat::Gemini => SessionSource::Gemini,
153            SessionFormat::Goose => SessionSource::Goose,
154        }
155    }
156}
157
158/// Metadata recovered from a session log.
159#[derive(Debug, Clone)]
160#[non_exhaustive]
161pub struct SessionMeta {
162    /// The tool that wrote the log.
163    pub source: SessionSource,
164    /// The session/rollout id.
165    pub session_id: Option<String>,
166    /// The model the session was running.
167    pub model: Option<String>,
168    /// The working directory the session ran in.
169    pub cwd: Option<PathBuf>,
170    /// The system / base-instructions prompt, when the log records it.
171    pub system_prompt: Option<String>,
172    /// Verbatim source-format header records (the Codex `session_meta` /
173    /// `turn_context` lines), preserved so re-export can replay the exact header
174    /// the original tool expects rather than guessing its required fields.
175    pub codex_headers: Vec<Value>,
176    /// Exact source lines for Codex execution/provenance records that affect
177    /// continuation semantics but must not be replayed as active events after
178    /// a foreign-format hop. Each entry records its original physical-line
179    /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
180    /// list in a namespaced extension; a later Codex export restores headers
181    /// from it while keeping compaction/rollback/review records non-operative,
182    /// avoiding a second rollback or compaction of the already-normalized view.
183    pub codex_provenance: Vec<Value>,
184    /// The OpenCode analogue of [`Self::codex_headers`]
185    /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
186    /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
187    /// absent), plus any captured `session_diff`/`todo` side-records — each
188    /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
189    /// shape `raw` uses, so a consumer can tell which storage key a header
190    /// record belongs to. These replay only via the direct-write fallback
191    /// (`Session::to_opencode_direct_write`); `opencode import` has no
192    /// ingestion path for `session_diff`/`todo` (S5).
193    pub opencode_headers: Vec<Value>,
194    /// Goose's native session-export object with `conversation` removed.
195    /// Goose stores sessions in SQLite but defines this JSON object as its
196    /// official import/export boundary. Keeping the shell lets an unchanged
197    /// direct round-trip remain byte exact while appended turns are spliced
198    /// into a stock-importable artifact without guessing native metadata.
199    pub goose_header: Option<Value>,
200    /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
201    /// stem). `None` for top-level sessions.
202    pub agent_id: Option<String>,
203    /// For a subagent session: the `tool_use_id` of the parent `Task` call that
204    /// spawned it, recovered from the parent transcript's tool result. Best
205    /// effort — `None` if the link could not be established.
206    pub parent_tool_use_id: Option<String>,
207    /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
208    /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
209    /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
210    /// `depth`). Empty for a plain top-level session. Used by
211    /// [`Session::reconstruct_tree`] to nest children under their parents.
212    pub lineage: std::collections::BTreeMap<String, String>,
213}
214
215impl SessionMeta {
216    fn new(source: SessionSource) -> Self {
217        SessionMeta {
218            source,
219            session_id: None,
220            model: None,
221            cwd: None,
222            system_prompt: None,
223            codex_headers: Vec::new(),
224            codex_provenance: Vec::new(),
225            opencode_headers: Vec::new(),
226            goose_header: None,
227            agent_id: None,
228            parent_tool_use_id: None,
229            lineage: std::collections::BTreeMap::new(),
230        }
231    }
232}
233
234/// A normalized, replayable conversation loaded from a tool's session log.
235#[derive(Debug, Clone)]
236pub struct Session {
237    /// Recovered metadata.
238    pub meta: SessionMeta,
239    /// The conversation, normalized to the OpenAI chat-completions shape.
240    pub messages: Vec<ChatMessage>,
241    /// Subagent (Task) sub-conversations. Claude Code stores these as separate
242    /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
243    /// discovers and attaches them here (each is a full [`Session`] whose
244    /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
245    pub subagents: Vec<Session>,
246    /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
247    /// captured via `split_lines_verbatim`, not the blank-skipping/trimming
248    /// `non_empty_lines` parse view, so a blank line, a CRLF (`\r\n`)
249    /// terminator, or trailing whitespace on a line all survive bit-for-bit
250    /// rather than being dropped/normalized away. Normalization into
251    /// `messages` is still lossy by design (it targets the OpenAI replay
252    /// shape), but these raw lines retain *everything* — including records
253    /// with no canonical representation (e.g. Claude `file-history-snapshot`)
254    /// — so a round-trip through the supercode-native format
255    /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
256    /// formats (Claude Code/Codex/Pi), for ANY input (see
257    /// [`Self::raw_trailing_newline`] for the one piece of information a line
258    /// list alone can't carry).
259    pub raw: Vec<String>,
260    /// Whether the source text `raw` was captured from ended with a trailing
261    /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
262    /// trailing newline from one that doesn't (both split into the same
263    /// lines) — this flag carries that fact out-of-band so
264    /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
265    /// original source bytes exactly, including the presence/absence of a
266    /// final newline. `true` for a `Session` whose `raw` isn't captured
267    /// verbatim from real source text (e.g. OpenCode's re-synthesized
268    /// export-document `raw`, or a `Session` assembled programmatically) —
269    /// matching the historical always-terminated-by-newline behavior for
270    /// those cases.
271    pub raw_trailing_newline: bool,
272    /// How many of `messages` (and, symmetrically, of `raw` — see below) came
273    /// from parsing the imported log, as opposed to being appended after
274    /// import. Set once, at the end of [`Self::from_claude_code_str`] /
275    /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
276    /// before [`Self::from_native_str`]'s subsequent loop reattaches any
277    /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
278    /// That loop pushes exactly one `raw` line and one message per appended
279    /// turn, so the two lists grow in lockstep from here on: the raw-prefix
280    /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
281    /// as `raw.len() - (messages.len() - imported_message_count)`, without a
282    /// second counter. `None` only when a `Session` is constructed some other
283    /// way than through those two loaders — splicing then has no boundary to
284    /// honor and treats every message as imported (equivalent to
285    /// `Some(messages.len())`).
286    pub imported_message_count: Option<usize>,
287    /// Whether `raw` was captured strict-verbatim from real source text
288    /// (`true`) or re-synthesized by this crate (`false`) — the fact
289    /// [`Self::raw_verbatim`]'s callers need to know before claiming a
290    /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
291    /// `true` for every line-oriented loader (`from_claude_code_str`,
292    /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
293    /// surface (`from_opencode_str`'s per-line loop) — each of those splits
294    /// `raw` directly out of the source text via `split_lines_verbatim`, so
295    /// replaying it reproduces the original bytes exactly. `false` for
296    /// OpenCode's EXPORT-DOCUMENT read surface
297    /// (`Session::from_opencode_export_doc`): a pretty-printed
298    /// `{info, messages:[...]}` document has no per-line envelope structure
299    /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
300    /// record — faithful in value, but not the original document's bytes.
301    /// A `Session` assembled programmatically (not through a `from_*_str`
302    /// loader) also defaults to `false` — no real source text was captured
303    /// at all.
304    pub raw_is_verbatim: bool,
305    /// PARITY-15: how many non-empty lines of the source text FAILED to
306    /// deserialize at all (a genuinely malformed/truncated JSON line — not
307    /// a well-formed-but-unmodeled record type, which is a normal,
308    /// intentional "skip", tracked separately by `crate::audit`). Every
309    /// line-oriented loader tolerates a stray corrupt line rather than
310    /// hard-failing the whole load (a single bad line must not make an
311    /// otherwise-healthy multi-thousand-line session unloadable) — but that
312    /// tolerance used to be completely invisible: `Session::load` returned
313    /// `Ok` either way, with no signal that anything was skipped. This
314    /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
315    /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
316    /// and for a `Session` assembled programmatically.
317    pub parse_error_lines: usize,
318    /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
319    /// failing — the same "say exactly what was given up" residue list
320    /// `harness.v1.sessions.export` already reports for artifacts.
321    ///
322    /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
323    /// transcript it cannot reconstruct exactly, which is what keeps
324    /// continuation/transfer/export guarantees intact. A non-empty list means
325    /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
326    /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
327    pub load_residue: Vec<String>,
328}
329
330impl Session {
331    /// The fidelity this reconstruction actually achieved.
332    ///
333    /// Same rule the export path applies to an artifact: named residue means
334    /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
335    /// [`Fidelity::ByteLossless`] and a re-synthesized one is
336    /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
337    /// session's: the whole reconstruction is only as faithful as its least
338    /// faithful part, and each child still reports its own residue where it
339    /// was measured.
340    pub fn load_fidelity(&self) -> Fidelity {
341        let own = if !self.load_residue.is_empty() {
342            Fidelity::Semantic
343        } else if self.raw_is_verbatim {
344            Fidelity::ByteLossless
345        } else {
346            Fidelity::ValueLossless
347        };
348        if own != Fidelity::Semantic
349            && self
350                .subagents
351                .iter()
352                .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
353        {
354            return Fidelity::Semantic;
355        }
356        own
357    }
358
359    /// Assemble a session from supercode's own flat store transcript (one
360    /// [`ChatMessage`] per JSONL line). These files are the native working
361    /// format written by Supercode's native session store, not a foreign
362    /// harness log, so routing them through format auto-detection would
363    /// misclassify them as an empty Claude Code session.
364    pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
365        Session {
366            meta: SessionMeta::new(SessionSource::Native),
367            messages,
368            subagents: Vec::new(),
369            raw: Vec::new(),
370            raw_trailing_newline: true,
371            imported_message_count: None,
372            raw_is_verbatim: false,
373            parse_error_lines: 0,
374            load_residue: Vec::new(),
375        }
376    }
377
378    /// Load a session, auto-detecting whether it's a Claude Code or Codex log
379    /// — or, when `path` looks like a SQLite database, a real OpenCode
380    /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
381    /// UTF-8 text read, so a binary `.db` file is routed to
382    /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
383    /// did not contain valid UTF-8" error (the confirmed footgun these items
384    /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
385    ///
386    /// A DIRECTORY is also accepted directly: `path` is probed with
387    /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
388    /// checks below (both of which assume a file and would otherwise surface
389    /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
390    /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
391    /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
392    /// `audit --format opencode` already does. A resolved `Sqlite` surface
393    /// loads exactly like pointing `load` at that `opencode*.db` file
394    /// directly (most-recently-updated top-level session). The legacy
395    /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
396    /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
397    /// that case returns a clear error naming the `.db` file / `audit` as the
398    /// way in, rather than silently doing nothing or crashing.
399    pub fn load(path: impl AsRef<Path>) -> Result<Session> {
400        Self::load_with_fidelity(path, Fidelity::ByteLossless)
401    }
402
403    /// Load a session at a declared [`Fidelity`].
404    ///
405    /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
406    /// record graph cannot be reconstructed exactly (the everyday case for a
407    /// Claude Code session that has been compacted or resumed across files,
408    /// where a live record's `parentUuid` names a record that was pruned)
409    /// still loads, stitched best-effort in transcript order, and names what
410    /// it gave up in [`Session::load_residue`]. Every stricter level keeps
411    /// the historical behavior — refuse loudly — because a continuation,
412    /// transfer or export built on a guessed graph is exactly the loss
413    /// supercode exists to prevent. Callers that go on to RESUME a session
414    /// must therefore use [`Session::load`].
415    pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
416        Self::load_with_fidelity_and_subagents(path, fidelity, true)
417    }
418
419    /// Load only the selected session's own transcript at a declared fidelity.
420    ///
421    /// This is the read-only frontend path: Claude Code can place hundreds of
422    /// child transcripts beside a parent, but a chat viewport displaying the
423    /// parent must not eagerly parse and transport that entire child tree.
424    /// Translation, continuation, export, and the ordinary [`Self::load`]
425    /// path keep attaching every subagent unchanged.
426    #[doc(hidden)]
427    pub fn load_parent_with_fidelity(
428        path: impl AsRef<Path>,
429        fidelity: Fidelity,
430    ) -> Result<Session> {
431        Self::load_with_fidelity_and_subagents(path, fidelity, false)
432    }
433
434    /// Load a bounded, parent-only transcript for human display.
435    ///
436    /// Unlike the continuation loader, Codex compaction records do not erase
437    /// earlier visible assistant turns here: the native rollout still holds
438    /// those records, and a scrollback view should show what the human saw,
439    /// not only the compacted context the next model call will receive.
440    #[doc(hidden)]
441    pub fn load_display_view(
442        path: impl AsRef<Path>,
443        fidelity: Fidelity,
444        message_limit: usize,
445    ) -> Result<Session> {
446        let path = path.as_ref();
447        if path.is_dir() || looks_like_sqlite(path) {
448            let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
449            truncate_session_messages(&mut session, message_limit);
450            return Ok(session);
451        }
452        let (source, text) = read_display_jsonl(path, message_limit)?;
453        let mut session = match source {
454            Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
455            Some(SessionSource::Gemini) => {
456                let mut session = Self::from_gemini_str(&text)?;
457                session.raw_is_verbatim = false;
458                session.load_residue.push(
459                    "display history is a bounded native-record projection, not a complete Gemini artifact"
460                        .to_string(),
461                );
462                session
463            }
464            Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
465            Some(SessionSource::Grok) => {
466                let mut session = Self::from_grok_str(&text)?;
467                session.capture_grok_path_metadata(path);
468                session
469            }
470            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
471            _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
472        };
473        truncate_session_messages(&mut session, message_limit);
474        Ok(session)
475    }
476
477    fn load_with_fidelity_and_subagents(
478        path: impl AsRef<Path>,
479        fidelity: Fidelity,
480        include_subagents: bool,
481    ) -> Result<Session> {
482        let path = path.as_ref();
483        if path.is_dir() {
484            return match detect_opencode_storage_surface(path) {
485                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
486                    Self::from_opencode_sqlite(&db_path, None)
487                }
488                Some((
489                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
490                    _,
491                )) => Err(crate::Error::Other(format!(
492                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
493                         supercode does not load directly — point `inspect`/`convert`/`resume` \
494                         at the store's `opencode*.db` SQLite file if this install has one, or \
495                         use `audit --format opencode {}` instead",
496                    path.display(),
497                    path.display()
498                ))),
499                None => Err(crate::Error::Other(format!(
500                    "{} is a directory, but no session file or OpenCode store was found in it \
501                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
502                     tree)",
503                    path.display()
504                ))),
505            };
506        }
507        if looks_like_sqlite(path) {
508            return Self::from_opencode_sqlite(path, None);
509        }
510        let text = read_utf8_or_diagnose(path)?;
511        match detect_source(&text) {
512            Some(SessionSource::Codex) => Self::from_codex_str(&text),
513            Some(SessionSource::Pi) => Self::from_pi_str(&text),
514            Some(SessionSource::Grok) => {
515                let mut session = Self::from_grok_str(&text)?;
516                session.capture_grok_path_metadata(path);
517                Ok(session)
518            }
519            Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
520            Some(SessionSource::Goose) => Self::from_goose_str(&text),
521            // IX-3: a detected OpenCode session must route to its own
522            // loader, not the Claude Code fallback below
523            // (`docs/interop/build-followups.md`).
524            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
525            _ => {
526                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
527                if include_subagents {
528                    session.attach_claude_subagents(path, &text, fidelity)?;
529                }
530                Ok(session)
531            }
532        }
533    }
534
535    /// Load a Claude Code transcript from a file, attaching any subagent
536    /// (`Task`) sub-conversations stored alongside it.
537    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
538        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
539    }
540
541    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
542    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
543    pub fn from_claude_code_with_fidelity(
544        path: impl AsRef<Path>,
545        fidelity: Fidelity,
546    ) -> Result<Session> {
547        let text = std::fs::read_to_string(path.as_ref())?;
548        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
549        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
550        Ok(session)
551    }
552
553    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
554    /// Claude Code transcript at `main_path`, linking each back to the parent
555    /// `Task` tool call via the agent id embedded in the parent's tool result.
556    fn attach_claude_subagents(
557        &mut self,
558        main_path: &Path,
559        main_text: &str,
560        fidelity: Fidelity,
561    ) -> Result<()> {
562        let Some(dir) = subagents_dir_for(main_path) else {
563            return Ok(());
564        };
565        let entries = std::fs::read_dir(&dir).map_err(|error| {
566            crate::Error::Other(format!(
567                "failed to enumerate Claude subagents at {}: {error}",
568                dir.display()
569            ))
570        })?;
571        let mut files = Vec::new();
572        for entry in entries {
573            let entry = entry.map_err(|error| {
574                crate::Error::Other(format!(
575                    "failed to enumerate Claude subagents at {}: {error}",
576                    dir.display()
577                ))
578            })?;
579            let path = entry.path();
580            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
581                files.push(path);
582            }
583        }
584        files.sort();
585
586        // Phase 1 — collect each subagent + its recovered agent id, without
587        // touching the main transcript yet.
588        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
589        for file in files {
590            let text = read_utf8_or_diagnose(&file).map_err(|error| {
591                crate::Error::Other(format!(
592                    "failed to read Claude subagent {}: {error}",
593                    file.display()
594                ))
595            })?;
596            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
597                Ok(sub) => sub,
598                // A read-only VIEW keeps the main conversation rather than
599                // losing the whole session to one unreconstructable child;
600                // the skip is named, not silent. Every stricter fidelity
601                // still propagates the child's failure.
602                Err(error) if fidelity.tolerates_residue() => {
603                    self.load_residue.push(format!(
604                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
605                        file.display()
606                    ));
607                    continue;
608                }
609                Err(error) => {
610                    return Err(crate::Error::Other(format!(
611                        "failed to reconstruct Claude subagent {}: {error}",
612                        file.display()
613                    )))
614                }
615            };
616            // agentId: prefer the file's own record, fall back to the filename stem.
617            let agent_id = first_agent_id(&text).or_else(|| {
618                file.file_stem()
619                    .and_then(|s| s.to_str())
620                    .map(|s| s.trim_start_matches("agent-").to_string())
621            });
622            collected.push((sub, agent_id));
623        }
624
625        // Phase 2 — single pass over the main transcript to index every
626        // requested agent id at once, then assign each subagent's parent by
627        // an O(1) lookup.
628        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
629        let index = parent_tool_use_index(main_text, &agent_ids);
630
631        for (mut sub, agent_id) in collected {
632            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
633            sub.meta.agent_id = agent_id;
634            self.subagents.push(sub);
635        }
636        Ok(())
637    }
638
639    /// Load a Codex rollout from a file.
640    pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
641        Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
642    }
643
644    /// Parse a Claude Code transcript from an in-memory JSONL string.
645    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
646        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
647    }
648
649    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
650    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
651    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
652        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
653        let mut messages = Vec::new();
654        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
655        // whitespace all preserved) — separate from the blank-skipping
656        // `non_empty_lines` walk just below, which still parses records only
657        // (a blank line is not a JSON record and must not become one).
658        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
659        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
660        // PARITY-15: a malformed/truncated line is still tolerated (a
661        // single bad line must not make an otherwise-healthy multi-
662        // thousand-line session unloadable) — but it's no longer INVISIBLE.
663        let mut parse_error_lines = 0usize;
664        let mut index = ClaudeReplayIndex::default();
665
666        // Claude transcripts are append-only trees, not linear chat logs.
667        // Build a lightweight graph index first so normalization sees the
668        // same single active, post-compaction branch Claude Code would
669        // resume. `raw` above deliberately remains the complete source.
670        for (line_index, line) in raw_lines.iter().enumerate() {
671            if line.trim().is_empty() {
672                continue;
673            }
674            let v: Value = match serde_json::from_str(line) {
675                Ok(v) => v,
676                Err(_) => {
677                    parse_error_lines += 1; // tolerate stray/corrupt lines
678                    continue;
679                }
680            };
681            capture_claude_meta(&v, &mut meta, line)?;
682            index.observe(line_index, &v)?;
683        }
684
685        let ClaudeReplaySelection {
686            lines: replay_lines,
687            residue: load_residue,
688        } = index.select_lines(fidelity)?;
689        let mut pending_assistant: Option<Value> = None;
690
691        for line_index in replay_lines {
692            let line = raw_lines[line_index];
693            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
694
695            if v.get("type").and_then(Value::as_str) == Some("assistant") {
696                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
697                    flush_claude_assistant(&mut pending_assistant, &mut messages);
698                    continue;
699                }
700                if let Some(pending) = pending_assistant.as_mut() {
701                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
702                        claude_assistant_message_id(&v) == Some(message_id)
703                    }) {
704                        merge_claude_assistant_chunk(pending, &v);
705                        continue;
706                    }
707                    flush_claude_assistant(&mut pending_assistant, &mut messages);
708                }
709                pending_assistant = Some(v);
710                continue;
711            }
712
713            flush_claude_assistant(&mut pending_assistant, &mut messages);
714
715            // WAVE-2 item 1: every Claude Code record carries a real
716            // top-level `timestamp` (ISO-8601) — provenance stamping below
717            // attaches it to every canonical `ChatMessage` this line
718            // produces, together with the record UUID and assistant model.
719            // `entry(...).or_insert_with` preserves any more-precise value a
720            // role-specific loader already supplied.
721            let before = messages.len();
722            match v.get("type").and_then(Value::as_str) {
723                Some("user") => push_claude_user(&v, &mut messages),
724                Some("assistant") => push_claude_assistant(&v, &mut messages),
725                Some("attachment") => push_claude_attachment(&v, &mut messages),
726                Some("system") => push_claude_system(&v, &mut messages),
727                _ => {} // mode, queue-operation, ... — skip
728            }
729            // UUID/model provenance remains meaningful even for legacy
730            // records that predate Claude Code's timestamp field.
731            capture_claude_record_provenance(&v, &mut messages[before..]);
732            restore_single_grok_message(&v, &mut messages[before..]);
733        }
734        flush_claude_assistant(&mut pending_assistant, &mut messages);
735
736        reorder_tool_results_after_calls(&mut messages);
737        ensure_tool_results_paired(&mut messages);
738        let imported_message_count = Some(messages.len());
739        Ok(Session {
740            meta,
741            messages,
742            subagents: Vec::new(),
743            raw,
744            raw_trailing_newline,
745            imported_message_count,
746            // Claude Code is line-oriented: `raw` is split directly out of
747            // the source text (strict-verbatim, IX-1).
748            raw_is_verbatim: true,
749            parse_error_lines,
750            load_residue,
751        })
752    }
753
754    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
755    ///
756    /// Codex stores subagents as separate rollout files linked to their parent
757    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
758    /// collection of sessions, this nests each child into its parent's
759    /// [`Session::subagents`] and returns only the roots. Children whose parent
760    /// isn't in the set are returned as roots themselves (best effort).
761    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
762        use std::collections::HashMap;
763        // Index each session's position by its session_id.
764        let mut idx: HashMap<String, usize> = HashMap::new();
765        for (i, s) in sessions.iter().enumerate() {
766            if let Some(id) = &s.meta.session_id {
767                idx.insert(id.clone(), i);
768            }
769        }
770        // Determine each session's parent (by index), if present in the set.
771        let parent_of: Vec<Option<usize>> = sessions
772            .iter()
773            .map(|s| {
774                s.meta
775                    .lineage
776                    .get("parent_thread_id")
777                    .and_then(|p| idx.get(p).copied())
778            })
779            .collect();
780
781        // Move children into parents, deepest-first so chains nest correctly.
782        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
783        let mut order: Vec<usize> = (0..slots.len()).collect();
784        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
785        for i in order {
786            if let Some(p) = parent_of[i] {
787                if p != i {
788                    if let Some(child) = slots[i].take() {
789                        if let Some(parent) = slots[p].as_mut() {
790                            parent.subagents.push(child);
791                        } else {
792                            slots[i] = Some(child); // parent already moved; keep as root
793                        }
794                    }
795                }
796            }
797        }
798        slots.into_iter().flatten().collect()
799    }
800
801    /// Parse a session of a known format from an in-memory JSONL string.
802    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
803        match format {
804            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
805            SessionFormat::Codex => Self::from_codex_str(jsonl),
806            SessionFormat::Pi => Self::from_pi_str(jsonl),
807            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
808            SessionFormat::Grok => Self::from_grok_str(jsonl),
809            SessionFormat::Gemini => Self::from_gemini_str(jsonl),
810            SessionFormat::Goose => Self::from_goose_str(jsonl),
811        }
812    }
813
814    /// Serialize this session to JSONL in the given format.
815    ///
816    /// The conversation is synthesized from the canonical messages, so this
817    /// works for sessions loaded from *either* tool as well as ones supercode
818    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
819    /// "export": format-specific framing that has no slot in the target may be
820    /// dropped, but the user/assistant/tool conversation is preserved.
821    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
822        match format {
823            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
824            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
825            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
826            SessionFormat::OpenCode => self.to_opencode_jsonl(),
827            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
828            SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
829            SessionFormat::Goose => Ok(self.to_goose_json()),
830        }
831    }
832
833    /// Export back to `format`, replaying the imported `raw` prefix
834    /// **verbatim** — original uuids/ids, real timestamps, and
835    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
836    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
837    /// is the session's own origin (`format.source() == self.meta.source`,
838    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
839    /// Only messages appended *after* import (tracked by
840    /// [`Self::imported_message_count`]) are synthesized, chained onto the
841    /// last original record found in the raw prefix.
842    ///
843    /// `session_id` of `Some(new)` rewrites the session id on every emitted
844    /// line, raw and synthesized alike (`sessionId` for Claude Code,
845    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
846    ///
847    /// Cross-format export (no verbatim prefix exists in the target dialect,
848    /// by definition) and a session with no `raw` lines both fall back
849    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
850    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
851    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
852    /// cross-format stays at the documented semantic tier.
853    pub fn to_jsonl_spliced(
854        &self,
855        format: SessionFormat,
856        session_id: Option<&str>,
857    ) -> Result<String> {
858        if self.parse_error_lines > 0
859            || self
860                .subagents
861                .iter()
862                .any(|subagent| subagent.parse_error_lines > 0)
863        {
864            return Err(Error::InvalidSession(
865                "refusing spliced export because the loaded session contains parse loss"
866                    .to_string(),
867            ));
868        }
869        if self.raw.is_empty() || format.source() != self.meta.source {
870            if let Some(session_id) = session_id {
871                let mut rewritten = self.clone();
872                rewritten.meta.session_id = Some(session_id.to_string());
873                return rewritten.to_jsonl(format);
874            }
875            return self.to_jsonl(format);
876        }
877        match format {
878            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
879            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
880            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
881            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
882            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
883            SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
884            SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
885        }
886    }
887
888    /// Write this session to `path` in the given format.
889    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
890        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
891        Ok(())
892    }
893
894    /// Reconstruct the exact source bytes this `Session` was loaded from,
895    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
896    /// inverse of the strict-verbatim capture those two fields record — see
897    /// `join_lines_verbatim`).
898    ///
899    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
900    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
901    /// original text, so this reproduces the original file byte-for-byte —
902    /// the P008/P009 diagonal-convert fix (`convert <file> --to
903    /// <same-format>` is byte-identical to `<file>`) is built on exactly
904    /// this. The one documented exception is an OpenCode **export-document**
905    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
906    /// is RE-SYNTHESIZED as one envelope line per record (see
907    /// `from_opencode_export_doc`'s contract), so this returns a
908    /// verbatim reproduction of THAT captured representation rather than the
909    /// original pretty-printed document — a known, narrow residue, not a
910    /// silent loss (the same records are all still present).
911    pub fn raw_verbatim(&self) -> String {
912        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
913    }
914
915    /// Serialize to the **supercode-native** lossless format: a header line
916    /// recording the original source, followed by every original JSONL line
917    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
918    /// schema and is necessarily lossy), this preserves *everything* — including
919    /// records with no canonical representation — so [`Self::from_native_str`]
920    /// reconstructs the session with full fidelity.
921    pub fn to_native_jsonl(&self) -> String {
922        let source = match self.meta.source {
923            SessionSource::ClaudeCode => "claude_code",
924            SessionSource::Codex => "codex",
925            SessionSource::Pi => "pi",
926            SessionSource::OpenCode => "opencode",
927            SessionSource::Grok => "grok",
928            SessionSource::Gemini => "gemini",
929            SessionSource::Goose => "goose",
930            // P5-3 safety-hardening fix: a natively-spawned session must
931            // never be written to disk labeled as an imported CC session.
932            SessionSource::Native => "native",
933        };
934        let header = serde_json::json!({
935            "supercode_native": 1,
936            "source": source,
937            // IX-1: carries whether the ORIGINAL imported source text ended
938            // with a trailing newline — `from_native_str` needs this to
939            // reconstruct the exact source bytes (not just the `raw` line
940            // list) when re-parsing the body with the per-source loader.
941            "raw_trailing_newline": self.raw_trailing_newline,
942        })
943        .to_string();
944        let mut out =
945            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
946        out.push_str(&header);
947        out.push('\n');
948        for line in &self.raw {
949            out.push_str(line);
950            out.push('\n');
951        }
952        out
953    }
954
955    /// Serialize to the **supercode-native v2** format: the same imported-body
956    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
957    /// followed by every `Session.raw` line verbatim), plus one
958    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
959    /// produced after import, which have no backing `raw` line of their own.
960    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
961    /// serde), so nothing the live agent loop records is lost to disk.
962    ///
963    /// `appended` is caller-supplied rather than inferred from
964    /// `self.messages`: A1 doesn't track which of `self.messages` came from
965    /// import vs. the live loop — that bookkeeping belongs to the live writer
966    /// built on top of this (A2/A3).
967    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
968        self.to_native_jsonl_v2_with_timestamp(appended, None)
969    }
970
971    pub(crate) fn to_native_jsonl_v2_with_timestamp(
972        &self,
973        appended: &[ChatMessage],
974        fixed_timestamp: Option<&str>,
975    ) -> String {
976        let source = match self.meta.source {
977            SessionSource::ClaudeCode => "claude_code",
978            SessionSource::Codex => "codex",
979            SessionSource::Pi => "pi",
980            SessionSource::OpenCode => "opencode",
981            SessionSource::Grok => "grok",
982            SessionSource::Gemini => "gemini",
983            SessionSource::Goose => "goose",
984            // P5-3 safety-hardening fix: a natively-spawned session must
985            // never be written to disk labeled as an imported CC session.
986            SessionSource::Native => "native",
987        };
988        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
989        // already parses CC sidechains + CX lineage on import"): a
990        // natively-spawned subagent's own `Session` carries its lineage on
991        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
992        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
993        // all, so a native-spawned child's lineage was lost the instant it
994        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
995        // (`skip_serializing_if`-equivalent via manual omission below) so a
996        // plain top-level session's header is byte-identical to before this
997        // change.
998        let mut header_obj = serde_json::json!({
999            "supercode_native": 2,
1000            "source": source,
1001            "session_id": self.meta.session_id,
1002            "created": fixed_timestamp
1003                .map(ToOwned::to_owned)
1004                .unwrap_or_else(crate::sidecar::now_rfc3339),
1005            // IX-1: see `to_native_jsonl`'s header field of the same name.
1006            "raw_trailing_newline": self.raw_trailing_newline,
1007        });
1008        if let Some(obj) = header_obj.as_object_mut() {
1009            if let Some(agent_id) = &self.meta.agent_id {
1010                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
1011            }
1012            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
1013                obj.insert(
1014                    "parent_tool_use_id".to_string(),
1015                    Value::String(parent_tool_use_id.clone()),
1016                );
1017            }
1018            if !self.meta.lineage.is_empty() {
1019                obj.insert(
1020                    "lineage".to_string(),
1021                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
1022                );
1023            }
1024        }
1025        let header = header_obj.to_string();
1026        let mut out =
1027            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
1028        out.push_str(&header);
1029        out.push('\n');
1030        for line in &self.raw {
1031            out.push_str(line);
1032            out.push('\n');
1033        }
1034        for (turn_index, msg) in appended.iter().enumerate() {
1035            let turn = match fixed_timestamp {
1036                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1037                    msg,
1038                    timestamp.to_string(),
1039                    turn_index as u64,
1040                ),
1041                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1042                    msg,
1043                    crate::sidecar::now_rfc3339(),
1044                    turn_index as u64,
1045                ),
1046            };
1047            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
1048            out.push('\n');
1049        }
1050        out
1051    }
1052
1053    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
1054    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
1055    /// exactly as before. A v2 file's appended `NativeTurn` records —
1056    /// discriminated by the `supercode_turn` key, which never appears in a v1
1057    /// body — are split out before the imported body is handed to the
1058    /// per-source loader, then reattached in file order: to `messages` (via
1059    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
1060    /// so a v2 file round-trips byte-for-byte through
1061    /// [`Self::to_native_jsonl_v2`] again.
1062    pub fn from_native_str(jsonl: &str) -> Result<Session> {
1063        // IX-1: the native WRAPPER's own lines are split verbatim (not via
1064        // the blank-skipping `non_empty_lines`) so that any `raw` line it
1065        // carries — which can itself be blank, CRLF-terminated, or
1066        // whitespace-padded, now that raw-capture is strict-verbatim —
1067        // survives being embedded in (and re-extracted from) this wrapper
1068        // bit-for-bit. The wrapper we ourselves emit never has a blank line
1069        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
1070        // record per line), so this is a behavior-preserving switch for any
1071        // native text this crate produced; it also makes a hand-fed/legacy
1072        // native string tolerated exactly as `non_empty_lines` used to.
1073        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
1074        let mut lines = all_lines.into_iter();
1075        let header = lines.next().unwrap_or("");
1076        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
1077        let source = hv.get("source").and_then(Value::as_str);
1078        // IX-1: whether the ORIGINAL imported source (before it was wrapped
1079        // in this native format) ended with a trailing newline — a property
1080        // of the pre-wrap source, not of this wrapper (which always
1081        // LF-terminates every line it writes, regardless). Missing on a
1082        // native file written before IX-1 (or a hand-built header in an
1083        // older test/sidecar) — default `true`, the historical
1084        // always-newline-terminated assumption.
1085        let raw_trailing_newline = hv
1086            .get("raw_trailing_newline")
1087            .and_then(Value::as_bool)
1088            .unwrap_or(true);
1089
1090        // Split appended NativeTurn records (v2) out of the imported body. A
1091        // v1 body never carries a `supercode_turn` key, so this is a no-op
1092        // there — one code path serves both versions.
1093        let mut body_lines: Vec<String> = Vec::new();
1094        let mut turn_lines: Vec<&str> = Vec::new();
1095        for line in lines {
1096            let is_turn = serde_json::from_str::<Value>(line)
1097                .ok()
1098                .is_some_and(|v| v.get("supercode_turn").is_some());
1099            if is_turn {
1100                turn_lines.push(line);
1101            } else {
1102                body_lines.push(line.to_string());
1103            }
1104        }
1105        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1106        // `body_lines.join("\n")` alone would silently gain a trailing
1107        // newline the original source never had (or lose one it did have).
1108        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1109
1110        // The remaining lines are the original log; re-parse with the right loader.
1111        let mut session = match source {
1112            Some("codex") => Self::from_codex_str(&body)?,
1113            Some("claude_code") => Self::from_claude_code_str(&body)?,
1114            Some("pi") => Self::from_pi_str(&body)?,
1115            Some("opencode") => Self::from_opencode_str(&body)?,
1116            Some("grok") => Self::from_grok_str(&body)?,
1117            Some("gemini") => Self::from_gemini_str(&body)?,
1118            Some("goose") => Self::from_goose_str(&body)?,
1119            // P5-3 safety-hardening fix: a natively-spawned session's body
1120            // is always empty (it never had any foreign-tool prefix to
1121            // begin with — see `SessionSource::Native`'s doc comment), so
1122            // any loader would parse it identically; `from_claude_code_str`
1123            // is reused purely as a blank-skeleton builder (empty
1124            // `raw`/`messages`), then its `meta.source` is corrected to
1125            // `Native` — never left mislabeled as `ClaudeCode`.
1126            Some("native") => {
1127                let mut s = Self::from_claude_code_str(&body)?;
1128                s.meta.source = SessionSource::Native;
1129                s
1130            }
1131            // No/unknown header — auto-detect the body.
1132            _ => match detect_source(&body) {
1133                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1134                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1135                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1136                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1137                Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
1138                Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
1139                _ => Self::from_claude_code_str(&body)?,
1140            },
1141        };
1142
1143        for line in turn_lines {
1144            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1145                Ok(turn) => {
1146                    session.raw.push(line.to_string());
1147                    session.messages.push(turn.into_message());
1148                }
1149                Err(_) => {
1150                    // A valid JSON object carrying the native-turn
1151                    // discriminator belongs to this wrapper, not to the
1152                    // imported body. If its required fields are malformed,
1153                    // count it as parse loss so every fail-loud caller can
1154                    // refuse continuation instead of silently dropping a
1155                    // native history record. Keep the rejected source line
1156                    // in `raw` as well: diagnostics must count it in their
1157                    // denominator, and even corrupt input must not disappear
1158                    // merely because it reached the parser.
1159                    session.raw.push(line.to_string());
1160                    session.parse_error_lines += 1;
1161                }
1162            }
1163        }
1164
1165        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1166        // header block): recover a natively-spawned subagent's own lineage
1167        // from the v2 header, when present. Overlays (rather than merges
1168        // into) whatever the per-source body loader may have already set on
1169        // `session.meta` — these three keys are ONLY ever written by
1170        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1171        // header that carries them is authoritative for a file this crate
1172        // produced.
1173        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1174            session.meta.agent_id = Some(agent_id.to_string());
1175        }
1176        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1177            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1178        }
1179        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1180            for (k, v) in lineage {
1181                if let Some(s) = v.as_str() {
1182                    session.meta.lineage.insert(k.clone(), s.to_string());
1183                }
1184            }
1185        }
1186
1187        Ok(session)
1188    }
1189
1190    /// The full-fidelity [`Session`] a sidecar denotes.
1191    ///
1192    /// The sidecar (native-v2 format, D1) is the imported body plus every
1193    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1194    /// tolerant lower-level native parser, this persisted-store entry point
1195    /// validates its framing header before loading anything: a missing,
1196    /// malformed, or unsupported header must never become a zero-message
1197    /// session that callers could continue as if it were complete.
1198    pub fn from_sidecar_str(s: &str) -> Result<Session> {
1199        let header = s.lines().next().ok_or_else(|| {
1200            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1201        })?;
1202        let value: Value = serde_json::from_str(header).map_err(|error| {
1203            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1204        })?;
1205        let version = value.get("supercode_native").and_then(Value::as_u64);
1206        if !matches!(version, Some(1 | 2)) {
1207            return Err(Error::InvalidSession(
1208                "sidecar header must declare supported `supercode_native` version 1 or 2"
1209                    .to_string(),
1210            ));
1211        }
1212        let source = value.get("source").and_then(Value::as_str);
1213        if !matches!(
1214            source,
1215            Some(
1216                "native"
1217                    | "claude_code"
1218                    | "codex"
1219                    | "gemini"
1220                    | "goose"
1221                    | "opencode"
1222                    | "pi"
1223                    | "grok"
1224            )
1225        ) {
1226            return Err(Error::InvalidSession(
1227                "sidecar header must declare a supported `source`".to_string(),
1228            ));
1229        }
1230        Self::from_native_str(s)
1231    }
1232
1233    /// Parse a Codex rollout from an in-memory JSONL string.
1234    pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1235        let mut meta = SessionMeta::new(SessionSource::Codex);
1236        let mut messages = Vec::new();
1237
1238        // First pass: collect the text of every assistant message that exists as
1239        // a canonical `response_item`. In normal sessions the streamed
1240        // `event_msg/agent_message` events duplicate these and are safely
1241        // skipped; in collab/multi-agent sessions the assistant narration lives
1242        // ONLY as `agent_message` events, so we recover the ones with no
1243        // response_item counterpart (deduping by exact text).
1244        let assistant_texts = collect_codex_assistant_texts(jsonl);
1245        // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1246        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1247        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1248        let mut pending_reasoning = String::new();
1249        let mut pending_reasoning_content = String::new();
1250        let mut pending_reasoning_encrypted = false;
1251        // PARITY-15: see `from_claude_code_str`'s identical counter.
1252        let mut parse_error_lines = 0usize;
1253        let mut restored_embedded_codex_provenance = false;
1254
1255        for (record_index, raw_line) in raw_lines.iter().enumerate() {
1256            let line = raw_line.trim();
1257            if line.is_empty() {
1258                continue;
1259            }
1260            let v: Value = match serde_json::from_str(line) {
1261                Ok(v) => v,
1262                Err(_) => {
1263                    parse_error_lines += 1;
1264                    continue;
1265                }
1266            };
1267            let payload = v.get("payload").unwrap_or(&Value::Null);
1268            if !restored_embedded_codex_provenance
1269                && v.get("type").and_then(Value::as_str) == Some("session_meta")
1270                && payload
1271                    .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1272                    .map(|extension| restore_codex_provenance(extension, &mut meta))
1273                    .transpose()?
1274                    .unwrap_or(false)
1275            {
1276                restored_embedded_codex_provenance = true;
1277            }
1278            if !restored_embedded_codex_provenance {
1279                capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1280            }
1281            // WAVE-2 item 1: every Codex record carries a real top-level
1282            // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1283            // line produces via `stamp_new_codex_messages` below, at each
1284            // arm that pushes messages.
1285            let line_ts = v.get("timestamp").and_then(Value::as_str);
1286
1287            match v.get("type").and_then(Value::as_str) {
1288                Some("session_meta") => {
1289                    capture_codex_session_meta(payload, &mut meta);
1290                    if !restored_embedded_codex_provenance {
1291                        meta.codex_headers.push(v.clone());
1292                    }
1293                }
1294                Some("turn_context") => {
1295                    if meta.model.is_none() {
1296                        meta.model = payload
1297                            .get("model")
1298                            .and_then(Value::as_str)
1299                            .map(str::to_string);
1300                    }
1301                    if !restored_embedded_codex_provenance {
1302                        meta.codex_headers.push(v.clone());
1303                    }
1304                }
1305                Some("response_item")
1306                    if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1307                {
1308                    // Retain reasoning (P3): summary text if any, the raw
1309                    // `content` chain-of-thought text if any (N2 — this used
1310                    // to be dropped despite `Coverage::Retained` claiming the
1311                    // whole item survived; see `crate::audit`'s doc comment),
1312                    // plus a flag for the opaque encrypted_content a
1313                    // same-model continuation can replay. Stashed onto the
1314                    // next assistant message below.
1315                    let summary = extract_text_content(payload.get("summary"));
1316                    if !summary.trim().is_empty() {
1317                        push_str_field(&mut pending_reasoning, &summary);
1318                    }
1319                    // N2: `content` is `null` on the vast majority of real
1320                    // turns (raw reasoning text is only ever populated for
1321                    // certain reasoning-transcript configurations) — guard
1322                    // on non-null BEFORE calling `extract_text_content`,
1323                    // since `Some(&Value::Null)` would otherwise fall into
1324                    // its `Some(other) => other.to_string()` arm and
1325                    // stringify to the literal text `"null"`.
1326                    if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1327                        let text = extract_text_content(Some(raw_content));
1328                        if !text.trim().is_empty() {
1329                            push_str_field(&mut pending_reasoning_content, &text);
1330                        }
1331                    }
1332                    // N1: `serde_json` returns `Some(&Value::Null)` for a
1333                    // present-but-null `encrypted_content` key — which is
1334                    // what EVERY real rollout's reasoning item carries
1335                    // (upstream always serializes the field, never
1336                    // `skip_serializing_if`, `codex-rs/protocol/src/
1337                    // models.rs:970-983`). The old `.is_some()` check
1338                    // false-flagged every single reasoning item as
1339                    // "encrypted" on real data; only a genuinely non-null
1340                    // value means the model actually returned an opaque
1341                    // blob that a same-model continuation could replay.
1342                    if payload
1343                        .get("encrypted_content")
1344                        .is_some_and(|v| !v.is_null())
1345                    {
1346                        pending_reasoning_encrypted = true;
1347                    }
1348                }
1349                Some("response_item") => {
1350                    let before = messages.len();
1351                    push_codex_item(payload, &mut messages);
1352                    // Attach any pending reasoning to a newly produced assistant turn.
1353                    if messages.len() > before
1354                        && (!pending_reasoning.is_empty()
1355                            || !pending_reasoning_content.is_empty()
1356                            || pending_reasoning_encrypted)
1357                    {
1358                        let is_assistant = messages
1359                            .last()
1360                            .map(|m| m.role == Role::Assistant)
1361                            .unwrap_or(false);
1362                        if is_assistant {
1363                            let last = messages.last_mut().expect("checked above");
1364                            if !pending_reasoning.is_empty() {
1365                                last.metadata.insert(
1366                                    "reasoning".to_string(),
1367                                    std::mem::take(&mut pending_reasoning),
1368                                );
1369                            }
1370                            if !pending_reasoning_content.is_empty() {
1371                                last.metadata.insert(
1372                                    "reasoning_content".to_string(),
1373                                    std::mem::take(&mut pending_reasoning_content),
1374                                );
1375                            }
1376                            if pending_reasoning_encrypted {
1377                                last.metadata
1378                                    .insert("reasoning_encrypted".to_string(), "true".to_string());
1379                                pending_reasoning_encrypted = false;
1380                            }
1381                        } else {
1382                            // N3: the item that just landed is NOT the
1383                            // assistant turn the pending reasoning was for
1384                            // (e.g. an aborted turn's reasoning directly
1385                            // followed by a user message) — the old code
1386                            // unconditionally cleared the pending state
1387                            // here, silently discarding it. Flush it as its
1388                            // own message instead, inserted just before the
1389                            // interrupting item so replay order stays
1390                            // chronological, keeping `Coverage::Retained`
1391                            // honest for this shape too.
1392                            let orphan = orphaned_reasoning_message(
1393                                &mut pending_reasoning,
1394                                &mut pending_reasoning_content,
1395                                &mut pending_reasoning_encrypted,
1396                            );
1397                            messages.insert(before, orphan);
1398                        }
1399                    }
1400                    stamp_new_codex_messages(&mut messages, before, line_ts);
1401                    restore_single_grok_message(payload, &mut messages[before..]);
1402                }
1403                // A compaction record replaces all prior turns with its
1404                // summarized `replacement_history` — exactly how Codex itself
1405                // resumes a compacted session.
1406                Some("compacted") => {
1407                    messages.clear();
1408                    if let Some(Value::Array(history)) = payload.get("replacement_history") {
1409                        for item in history {
1410                            push_codex_item(item, &mut messages);
1411                        }
1412                    }
1413                    // `replacement_history` items carry no per-item
1414                    // timestamp of their own (observed corpora) — the
1415                    // `compacted` record's own timestamp (when it happened)
1416                    // is the best-effort real source for every message it
1417                    // synthesizes, so it stamps the whole rebuilt vec (index
1418                    // 0, since `clear()` reset it above).
1419                    stamp_new_codex_messages(&mut messages, 0, line_ts);
1420                    // IX-6 fix: replaying `replacement_history` through
1421                    // `push_codex_item` can leave the LAST replayed message
1422                    // marked `__codex_open_turn` (if it's an assistant
1423                    // `message`, per the combined-turn merge below). That
1424                    // marker must not survive past the compaction boundary —
1425                    // a live `function_call` arriving after this record is a
1426                    // NEW turn, not a continuation of the compaction
1427                    // summary's synthetic turn, so it must not merge into it.
1428                    if let Some(last) = messages.last_mut() {
1429                        last.metadata.remove("__codex_open_turn");
1430                    }
1431                }
1432                Some("event_msg")
1433                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1434                {
1435                    let before = messages.len();
1436                    let text = agent_message_text(payload);
1437                    if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1438                        push_assistant(&mut messages, text, Vec::new());
1439                        if let Some(last) = messages.last_mut() {
1440                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1441                                last.metadata.insert("phase".to_string(), phase.to_string());
1442                            }
1443                        }
1444                    }
1445                    stamp_new_codex_messages(&mut messages, before, line_ts);
1446                }
1447                // The user rolled back (undid) the last N turns — replay must
1448                // drop them so the reloaded conversation matches what the user
1449                // actually kept.
1450                Some("event_msg")
1451                    if payload.get("type").and_then(Value::as_str)
1452                        == Some("thread_rolled_back") =>
1453                {
1454                    let n = payload
1455                        .get("num_turns")
1456                        .and_then(Value::as_u64)
1457                        .unwrap_or(1);
1458                    for _ in 0..n {
1459                        remove_last_turn(&mut messages);
1460                    }
1461                }
1462                // The natural-language goal assigned to this thread (sometimes
1463                // the only place the objective text is recorded).
1464                Some("event_msg")
1465                    if payload.get("type").and_then(Value::as_str)
1466                        == Some("thread_goal_updated") =>
1467                {
1468                    let before = messages.len();
1469                    let goal = payload.get("goal");
1470                    if let Some(obj) = goal
1471                        .and_then(|g| g.get("objective"))
1472                        .and_then(Value::as_str)
1473                    {
1474                        if !obj.trim().is_empty() {
1475                            messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1476                            // D4: `goal.objective` alone used to be the ONLY
1477                            // captured field, but the audit labeled this
1478                            // `Retained` as if the whole record survived.
1479                            // `goal.status`/`goal.tokenBudget` (real
1480                            // `ThreadGoal` wire fields, camelCase) are
1481                            // captured too so that label is honest — see
1482                            // `crate::audit::event_msg_coverage`'s doc
1483                            // comment.
1484                            if let Some(last) = messages.last_mut() {
1485                                if let Some(status) =
1486                                    goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1487                                {
1488                                    last.metadata
1489                                        .insert("goal_status".to_string(), status.to_string());
1490                                }
1491                                if let Some(budget) = goal
1492                                    .and_then(|g| g.get("tokenBudget"))
1493                                    .and_then(Value::as_i64)
1494                                {
1495                                    last.metadata.insert(
1496                                        "goal_token_budget".to_string(),
1497                                        budget.to_string(),
1498                                    );
1499                                }
1500                            }
1501                        }
1502                    }
1503                    stamp_new_codex_messages(&mut messages, before, line_ts);
1504                }
1505                // Code-review output — unique assistant-generated content with no
1506                // `message` counterpart.
1507                Some("event_msg")
1508                    if payload.get("type").and_then(Value::as_str)
1509                        == Some("exited_review_mode") =>
1510                {
1511                    let before = messages.len();
1512                    if let Some(review) = payload.get("review_output") {
1513                        let text = review
1514                            .get("overall_explanation")
1515                            .and_then(Value::as_str)
1516                            .map(str::to_string)
1517                            .unwrap_or_else(|| review.to_string());
1518                        push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1519                        // D4: `overall_explanation` alone used to be the ONLY
1520                        // captured field, but the audit labeled this
1521                        // `Retained` as if `review_output.findings` survived
1522                        // too. Capture `findings` verbatim (as JSON, onto
1523                        // metadata) so that label is honest — this is the
1524                        // only place review-mode findings (title/body/
1525                        // confidence_score/priority/code_location) live.
1526                        if let Some(findings) = review.get("findings") {
1527                            if findings.as_array().is_some_and(|a| !a.is_empty()) {
1528                                if let Some(last) = messages.last_mut() {
1529                                    if let Ok(s) = serde_json::to_string(findings) {
1530                                        last.metadata.insert("review_findings".to_string(), s);
1531                                    }
1532                                }
1533                            }
1534                        }
1535                        // N4: `overall_correctness`/`overall_confidence_score`
1536                        // are the review's actual verdict — distinct from the
1537                        // findings list and the explanation prose already
1538                        // captured above — and were neither captured nor
1539                        // disclosed as residue while the audit doc stayed
1540                        // silent about them. Capture both onto the same
1541                        // message's metadata, same pattern as `findings`.
1542                        if let Some(last) = messages.last_mut() {
1543                            if let Some(correctness) =
1544                                review.get("overall_correctness").and_then(Value::as_str)
1545                            {
1546                                last.metadata.insert(
1547                                    "review_overall_correctness".to_string(),
1548                                    correctness.to_string(),
1549                                );
1550                            }
1551                            if let Some(score) = review
1552                                .get("overall_confidence_score")
1553                                .and_then(Value::as_f64)
1554                            {
1555                                last.metadata.insert(
1556                                    "review_overall_confidence_score".to_string(),
1557                                    score.to_string(),
1558                                );
1559                            }
1560                        }
1561                    }
1562                    stamp_new_codex_messages(&mut messages, before, line_ts);
1563                }
1564                _ => {} // other event_msg, token_count, ... — UI events, skip
1565            }
1566        }
1567
1568        // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1569        // shape a real rollout can leave behind (the process was
1570        // interrupted mid-turn, after the model reasoned but before it
1571        // replied — end of file, or a rollback/compaction boundary that
1572        // clears the pending state some other way) — the old code silently
1573        // dropped it here (nothing ever consumed the pending buffers once
1574        // the loop ended). Flush it as its own trailing message instead, so
1575        // `Coverage::Retained` holds for this shape too. Superset of the
1576        // independently-discovered PARITY-11 fix: also folds in
1577        // `pending_reasoning_content` (the raw chain-of-thought, distinct
1578        // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1579        // message` helper, which the interrupted-by-a-user-message shape
1580        // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1581        // relies on — a trailing-EOF-only flush here would miss that case.
1582        if !pending_reasoning.is_empty()
1583            || !pending_reasoning_content.is_empty()
1584            || pending_reasoning_encrypted
1585        {
1586            let orphan = orphaned_reasoning_message(
1587                &mut pending_reasoning,
1588                &mut pending_reasoning_content,
1589                &mut pending_reasoning_encrypted,
1590            );
1591            messages.push(orphan);
1592        }
1593
1594        ensure_tool_results_paired(&mut messages);
1595        // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1596        // combined-turn merge above — strip it so it never leaks out as
1597        // visible `ChatMessage` metadata.
1598        for m in &mut messages {
1599            m.metadata.remove("__codex_open_turn");
1600            if m.metadata
1601                .remove("__grok_remove_synthetic_turn_id")
1602                .is_some()
1603            {
1604                m.metadata.remove("turn_id");
1605            }
1606        }
1607        let imported_message_count = Some(messages.len());
1608        Ok(Session {
1609            meta,
1610            messages,
1611            subagents: Vec::new(),
1612            raw,
1613            raw_trailing_newline,
1614            imported_message_count,
1615            // Codex is line-oriented: `raw` is split directly out of the
1616            // source text (strict-verbatim, IX-1).
1617            raw_is_verbatim: true,
1618            parse_error_lines,
1619            load_residue: Vec::new(),
1620        })
1621    }
1622
1623    /// Parse a Codex rollout as bounded human-visible history rather than as
1624    /// resumable model context. This deliberately ignores outer `compacted`
1625    /// replacement semantics: the original `response_item` records remain in
1626    /// the rollout and are the authoritative UI history.
1627    fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
1628        let mut meta = SessionMeta::new(SessionSource::Codex);
1629        let mut messages: Vec<ChatMessage> = Vec::new();
1630        let mut preceding_user = None;
1631        let mut parse_error_lines = 0usize;
1632        let mut record_count = 0usize;
1633        let retain = message_limit.max(1).saturating_add(64);
1634        let mut canonical_assistant_texts = HashSet::new();
1635
1636        for raw_line in non_empty_lines(jsonl) {
1637            record_count += 1;
1638            let value: Value = match serde_json::from_str(raw_line) {
1639                Ok(value) => value,
1640                Err(_) => {
1641                    parse_error_lines += 1;
1642                    continue;
1643                }
1644            };
1645            let payload = value.get("payload").unwrap_or(&Value::Null);
1646            let line_ts = value.get("timestamp").and_then(Value::as_str);
1647            match value.get("type").and_then(Value::as_str) {
1648                Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
1649                Some("turn_context") if meta.model.is_none() => {
1650                    meta.model = payload
1651                        .get("model")
1652                        .and_then(Value::as_str)
1653                        .map(str::to_string);
1654                }
1655                Some("response_item")
1656                    if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
1657                {
1658                    let assistant_text = (payload.get("type").and_then(Value::as_str)
1659                        == Some("message")
1660                        && payload.get("role").and_then(Value::as_str) == Some("assistant"))
1661                    .then(|| extract_text_content(payload.get("content")))
1662                    .filter(|text| !text.trim().is_empty());
1663                    if let Some(text) = assistant_text.as_deref() {
1664                        if let Some(index) = messages.iter().rposition(|message| {
1665                            message.metadata.contains_key("codex_event_message")
1666                                && message.content.as_deref() == Some(text)
1667                        }) {
1668                            messages.remove(index);
1669                        }
1670                        canonical_assistant_texts.insert(text.trim().to_string());
1671                    }
1672                    let before = messages.len();
1673                    push_codex_item(payload, &mut messages);
1674                    stamp_new_codex_messages(&mut messages, before, line_ts);
1675                    restore_single_grok_message(payload, &mut messages[before..]);
1676                }
1677                Some("event_msg")
1678                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1679                {
1680                    let text = agent_message_text(payload);
1681                    if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
1682                        let before = messages.len();
1683                        push_assistant(&mut messages, text, Vec::new());
1684                        if let Some(last) = messages.last_mut() {
1685                            last.metadata
1686                                .insert("codex_event_message".to_string(), "true".to_string());
1687                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1688                                last.metadata.insert("phase".to_string(), phase.to_string());
1689                            }
1690                        }
1691                        stamp_new_codex_messages(&mut messages, before, line_ts);
1692                    }
1693                }
1694                // `compacted` changes continuation context, not what was
1695                // already visible in scrollback. Other event records are UI
1696                // lifecycle noise or duplicate canonical response items.
1697                _ => {}
1698            }
1699            if messages.len() > retain {
1700                let remove = messages.len() - retain;
1701                for message in messages.drain(..remove) {
1702                    if message.role == Role::User {
1703                        preceding_user = Some(message);
1704                    }
1705                }
1706            }
1707        }
1708
1709        for message in &mut messages {
1710            message.metadata.remove("__codex_open_turn");
1711            message.metadata.remove("codex_event_message");
1712            if message
1713                .metadata
1714                .remove("__grok_remove_synthetic_turn_id")
1715                .is_some()
1716            {
1717                message.metadata.remove("turn_id");
1718            }
1719        }
1720        truncate_messages_with_anchor(&mut messages, message_limit, preceding_user);
1721        let imported_message_count = Some(messages.len());
1722        Ok(Session {
1723            meta,
1724            messages,
1725            subagents: Vec::new(),
1726            // Preserve the cheap count without retaining hundreds of
1727            // megabytes of source lines in a display-only value.
1728            raw: vec![String::new(); record_count],
1729            raw_trailing_newline: jsonl.ends_with('\n'),
1730            imported_message_count,
1731            raw_is_verbatim: false,
1732            parse_error_lines,
1733            load_residue: vec![
1734                "display history is a bounded native-record projection, not resumable model context"
1735                    .to_string(),
1736            ],
1737        })
1738    }
1739
1740    /// Load a Pi session from a file.
1741    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1742        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1743    }
1744
1745    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1746    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1747    ///
1748    /// Line 1 is the `session` header; every other line is one `SessionEntry`
1749    /// in a tree keyed by `id`/`parentId` — file order is append order, not
1750    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1751    /// exactly like Claude Code/Codex). `messages` is the **active path
1752    /// only**: pi's own leaf rule is "the last entry in file order"
1753    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1754    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1755    /// state records (`thinking_level_change`/`model_change`/`custom`/
1756    /// `session_info`) are never visited by that walk — they survive in
1757    /// `raw` only, pi's defining residue (§1.1).
1758    ///
1759    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1760    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1761    /// `custom`) produces no canonical message — raw-only survival, never a
1762    /// panic — and the Pi corpus audit turns that into a
1763    /// visible coverage failure rather than a silent drop.
1764    ///
1765    /// Same fail-loud discipline applies to `ImageContent` blocks
1766    /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1767    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1768    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1769    /// cites the containing union) — a follow-up TR tracks confirming it
1770    /// against a real corpus. Until then, an image block that doesn't match
1771    /// that shape never gets silently synthesized as an empty/corrupt
1772    /// `image_url` part; the containing message survives in `raw` only and
1773    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1774    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1775        let mut meta = SessionMeta::new(SessionSource::Pi);
1776        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1777        // blank-skipping PARSE walk (`lines_v`) below, which must keep
1778        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1779        // records (a blank line is never a record, on either view).
1780        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1781        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1782        let non_empty_line_count = non_empty_lines(jsonl).count();
1783        let lines_v: Vec<Value> = non_empty_lines(jsonl)
1784            .filter_map(|l| serde_json::from_str(l).ok())
1785            .collect();
1786        // PARITY-15: every line that failed to even deserialize as JSON at
1787        // all (never mind whether it then parsed as a recognized
1788        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1789        // counter.
1790        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1791
1792        if let Some(header) = lines_v.first() {
1793            capture_pi_header(header, &mut meta)?;
1794        }
1795
1796        // Every non-header entry that parses as an object carrying an `id`.
1797        // (A line that fails to parse, or a header re-parsed as an entry,
1798        // simply never enters `by_id` — it survives in `raw` only, exactly
1799        // like a malformed/non-conversational line in the other loaders.)
1800        struct PiEntry {
1801            id: String,
1802            parent_id: Option<String>,
1803            value: Value,
1804        }
1805        let mut entries: Vec<PiEntry> = Vec::new();
1806        let mut by_id: HashMap<String, usize> = HashMap::new();
1807        for v in lines_v.iter().skip(1) {
1808            let Some(id) = v.get("id").and_then(Value::as_str) else {
1809                continue;
1810            };
1811            let parent_id = v
1812                .get("parentId")
1813                .and_then(Value::as_str)
1814                .map(str::to_string);
1815            by_id.insert(id.to_string(), entries.len());
1816            entries.push(PiEntry {
1817                id: id.to_string(),
1818                parent_id,
1819                value: v.clone(),
1820            });
1821        }
1822
1823        if entries.is_empty() {
1824            return Ok(Session {
1825                meta,
1826                messages: Vec::new(),
1827                subagents: Vec::new(),
1828                raw,
1829                raw_trailing_newline,
1830                imported_message_count: Some(0),
1831                // Pi is line-oriented: `raw` is split directly out of the
1832                // source text (strict-verbatim, IX-1), even for this
1833                // no-entries early return.
1834                raw_is_verbatim: true,
1835                parse_error_lines,
1836                load_residue: Vec::new(),
1837            });
1838        }
1839
1840        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1841        // necessarily a `message` entry — a trailing `label`/`session_info`
1842        // still anchors the walk correctly since the walk just follows
1843        // `parentId` regardless of the leaf's own type.
1844        let leaf_idx = entries.len() - 1;
1845        let mut chain_rev: Vec<usize> = Vec::new();
1846        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1847        let mut guard = 0usize;
1848        while let Some(id) = cur {
1849            let Some(&idx) = by_id.get(&id) else { break };
1850            chain_rev.push(idx);
1851            cur = entries[idx].parent_id.clone();
1852            guard += 1;
1853            if guard > entries.len() + 1 {
1854                break; // cycle guard — malformed parentId chain
1855            }
1856        }
1857        chain_rev.reverse();
1858        let active = chain_rev; // indices into `entries`, root..leaf order
1859
1860        let pos_in_active: HashMap<&str, usize> = active
1861            .iter()
1862            .enumerate()
1863            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1864            .collect();
1865
1866        // First pass: compaction discipline (§2.1 S3) — every message from an
1867        // entry before the LATEST `firstKeptEntryId` on the active path is
1868        // excluded from replay (`compacted_out`), mirroring pi's own
1869        // `buildContextEntries` slice (`sm:414-450`).
1870        let mut kept_from_pos = 0usize;
1871        for &idx in &active {
1872            let e = &entries[idx];
1873            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1874                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1875                    if let Some(&p) = pos_in_active.get(fk) {
1876                        kept_from_pos = kept_from_pos.max(p);
1877                    }
1878                }
1879            }
1880        }
1881
1882        let mut messages = Vec::new();
1883        let mut current_model: Option<String> = None;
1884        for (pos, &idx) in active.iter().enumerate() {
1885            let e = &entries[idx];
1886            let v = &e.value;
1887            let entry_ts = v
1888                .get("timestamp")
1889                .and_then(Value::as_str)
1890                .map(str::to_string);
1891            let before = messages.len();
1892            match v.get("type").and_then(Value::as_str) {
1893                Some("message") => {
1894                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1895                    match msg_v.get("role").and_then(Value::as_str) {
1896                        Some("user") => push_pi_user(&msg_v, &mut messages),
1897                        Some("assistant") => {
1898                            push_pi_assistant(&msg_v, &mut messages);
1899                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1900                                current_model = Some(m.to_string());
1901                            }
1902                        }
1903                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1904                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1905                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1906                        // OPEN UNION (S6): any other role — raw-only survival.
1907                        _ => {}
1908                    }
1909                }
1910                Some("custom_message") => push_pi_custom_common(v, &mut messages),
1911                Some("compaction") => push_pi_compaction(v, &mut messages),
1912                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1913                Some("model_change") => {
1914                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1915                        current_model = Some(m.to_string());
1916                    }
1917                }
1918                Some("session_info") => {
1919                    if let Some(name) = v.get("name").and_then(Value::as_str) {
1920                        if !name.is_empty() {
1921                            meta.lineage
1922                                .insert("session_name".to_string(), name.to_string());
1923                        }
1924                    }
1925                }
1926                // thinking_level_change, custom (entry-level state), label —
1927                // no clean home, raw-only (§2.3).
1928                _ => {}
1929            }
1930            let is_summary = matches!(
1931                v.get("type").and_then(Value::as_str),
1932                Some("compaction") | Some("branch_summary")
1933            );
1934            for m in &mut messages[before..] {
1935                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1936                if let Some(p) = &e.parent_id {
1937                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
1938                }
1939                if let Some(ts) = &entry_ts {
1940                    m.metadata
1941                        .entry("timestamp".to_string())
1942                        .or_insert_with(|| ts.clone());
1943                }
1944                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1945                // is pi's authoritative, always-monotonic-in-file-order
1946                // wall-clock (mandatory on every entry) and wins whenever
1947                // present. The nested `message.timestamp` (unix-ms) is only
1948                // reached here — via `entry(...).or_insert_with`, so it
1949                // never overwrites the entry-level value — in the rare case
1950                // an entry lacks its own `timestamp`. This intentionally
1951                // does NOT prefer the msg-level field even though it LOOKS
1952                // more precise: unlike the entry-level timestamp, it is not
1953                // guaranteed monotonic with this loader's root->leaf
1954                // linearization (e.g. a rewound-branch entry can carry an
1955                // earlier msg-level clock reading than its file-order
1956                // neighbors), and OpenCode's own loader re-sorts messages by
1957                // this canonical timestamp — a non-monotonic source would
1958                // silently scramble replay order on a pi->opencode hop.
1959                if let Some(ms) = v
1960                    .get("message")
1961                    .and_then(|mm| mm.get("timestamp"))
1962                    .and_then(Value::as_u64)
1963                {
1964                    m.metadata
1965                        .entry("timestamp".to_string())
1966                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
1967                }
1968                // A compaction/branch-summary message IS the retained marker
1969                // — never mark it excluded, regardless of its own position.
1970                if !is_summary && pos < kept_from_pos {
1971                    m.metadata
1972                        .insert("compacted_out".to_string(), "true".to_string());
1973                }
1974            }
1975            restore_single_grok_message(v, &mut messages[before..]);
1976            for message in &mut messages[before..] {
1977                restore_tool_outcome_extension(v, message);
1978            }
1979        }
1980
1981        meta.model = current_model;
1982        ensure_tool_results_paired(&mut messages);
1983        let imported_message_count = Some(messages.len());
1984        Ok(Session {
1985            meta,
1986            messages,
1987            subagents: Vec::new(),
1988            raw,
1989            raw_trailing_newline,
1990            imported_message_count,
1991            // Pi is line-oriented: `raw` is split directly out of the
1992            // source text (strict-verbatim, IX-1).
1993            raw_is_verbatim: true,
1994            parse_error_lines,
1995            load_residue: Vec::new(),
1996        })
1997    }
1998
1999    /// Load Grok's resumable `chat_history.jsonl` transcript.
2000    ///
2001    /// The surrounding session directory carries the session id, workspace,
2002    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
2003    /// itself while this path-aware entry point overlays that directory
2004    /// metadata.
2005    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
2006        let path = path.as_ref();
2007        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
2008        session.capture_grok_path_metadata(path);
2009        Ok(session)
2010    }
2011
2012    /// Parse Grok's line-oriented `chat_history.jsonl` format.
2013    ///
2014    /// Conversational records are `user`, `assistant`, and `tool_result`.
2015    /// `system` is the regenerated base prompt and is retained in
2016    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2017    /// state remain byte-exact in [`Session::raw`] but are intentionally not
2018    /// replayed as chat turns.
2019    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2020        let mut meta = SessionMeta::new(SessionSource::Grok);
2021        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2022        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2023        let mut messages = Vec::new();
2024        let mut parse_error_lines = 0usize;
2025        let mut tool_names: HashMap<String, String> = HashMap::new();
2026
2027        for line in non_empty_lines(jsonl) {
2028            let value: Value = match serde_json::from_str(line) {
2029                Ok(value) => value,
2030                Err(_) => {
2031                    parse_error_lines += 1;
2032                    continue;
2033                }
2034            };
2035            restore_codex_provenance_from_top_level(&value, &mut meta)?;
2036            match value.get("type").and_then(Value::as_str) {
2037                Some("system") => {
2038                    if meta.system_prompt.is_none() {
2039                        meta.system_prompt = value
2040                            .get("content")
2041                            .and_then(Value::as_str)
2042                            .map(str::to_string);
2043                    }
2044                }
2045                Some("user") => {
2046                    let content = extract_text_content(value.get("content"));
2047                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
2048                        == Some("supercode_system_event")
2049                    {
2050                        Role::System
2051                    } else {
2052                        Role::User
2053                    };
2054                    let content = if role == Role::User {
2055                        match grok_human_user_text(&content) {
2056                            Some(content) => content,
2057                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2058                                String::new()
2059                            }
2060                            None => continue,
2061                        }
2062                    } else {
2063                        content
2064                    };
2065                    let mut message = ChatMessage {
2066                        role,
2067                        content: Some(content),
2068                        content_parts: None,
2069                        tool_calls: None,
2070                        tool_call_id: None,
2071                        name: None,
2072                        metadata: Default::default(),
2073                    };
2074                    capture_grok_scalar_metadata(
2075                        &value,
2076                        &mut message,
2077                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2078                    );
2079                    restore_grok_message_extension(&value, &mut message);
2080                    messages.push(message);
2081                }
2082                Some("assistant") => {
2083                    let calls: Vec<ToolCall> = value
2084                        .get("tool_calls")
2085                        .and_then(Value::as_array)
2086                        .into_iter()
2087                        .flatten()
2088                        .filter_map(|call| {
2089                            let id = call.get("id")?.as_str()?.to_string();
2090                            let name = call.get("name")?.as_str()?.to_string();
2091                            let arguments = call
2092                                .get("arguments")
2093                                .map(value_to_arg_string)
2094                                .unwrap_or_else(|| "{}".to_string());
2095                            tool_names.insert(id.clone(), name.clone());
2096                            Some(function_call(&id, &name, arguments))
2097                        })
2098                        .collect();
2099                    let content = value
2100                        .get("content")
2101                        .and_then(Value::as_str)
2102                        .filter(|content| !content.is_empty())
2103                        .map(str::to_string);
2104                    let mut message = ChatMessage {
2105                        role: Role::Assistant,
2106                        content,
2107                        content_parts: None,
2108                        tool_calls: (!calls.is_empty()).then_some(calls),
2109                        tool_call_id: None,
2110                        name: None,
2111                        metadata: Default::default(),
2112                    };
2113                    capture_grok_scalar_metadata(
2114                        &value,
2115                        &mut message,
2116                        &["model_id", "model_fingerprint", "reasoning_effort"],
2117                    );
2118                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2119                        meta.model = Some(model.to_string());
2120                    }
2121                    restore_grok_message_extension(&value, &mut message);
2122                    messages.push(message);
2123                }
2124                Some("tool_result") => {
2125                    let id = value
2126                        .get("tool_call_id")
2127                        .and_then(Value::as_str)
2128                        .unwrap_or_default();
2129                    let content = value
2130                        .get("content")
2131                        .map(|value| match value {
2132                            Value::String(text) => text.clone(),
2133                            other => extract_text_content(Some(other)),
2134                        })
2135                        .unwrap_or_default();
2136                    let mut message = tool_message(id, content);
2137                    message.name = tool_names.get(id).cloned();
2138                    restore_grok_message_extension(&value, &mut message);
2139                    messages.push(message);
2140                }
2141                // `reasoning` contains encrypted chain-of-thought and
2142                // `backend_tool_call` is execution bookkeeping. Both survive
2143                // verbatim in raw without being replayed to another model.
2144                _ => {}
2145            }
2146        }
2147
2148        ensure_tool_results_paired(&mut messages);
2149        let imported_message_count = Some(messages.len());
2150        Ok(Session {
2151            meta,
2152            messages,
2153            subagents: Vec::new(),
2154            raw,
2155            raw_trailing_newline,
2156            imported_message_count,
2157            raw_is_verbatim: true,
2158            parse_error_lines,
2159            load_residue: Vec::new(),
2160        })
2161    }
2162
2163    fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2164        let Some(session_dir) = transcript.parent() else {
2165            return;
2166        };
2167        self.meta.session_id = session_dir
2168            .file_name()
2169            .and_then(|name| name.to_str())
2170            .map(str::to_string);
2171        self.meta.cwd = session_dir
2172            .parent()
2173            .and_then(Path::file_name)
2174            .and_then(|name| name.to_str())
2175            .and_then(percent_decode_path)
2176            .map(PathBuf::from);
2177
2178        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2179            return;
2180        };
2181        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2182            return;
2183        };
2184        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2185            self.meta.model = Some(model.to_string());
2186        }
2187        for (source, target) in [
2188            ("generated_title", "session_name"),
2189            ("created_at", "created_at"),
2190            ("updated_at", "updated_at"),
2191            ("chat_format_version", "grok_chat_format_version"),
2192        ] {
2193            if let Some(value) = summary.get(source) {
2194                self.meta.lineage.insert(
2195                    target.to_string(),
2196                    value
2197                        .as_str()
2198                        .map(str::to_string)
2199                        .unwrap_or_else(|| value.to_string()),
2200                );
2201            }
2202        }
2203    }
2204
2205    /// Load a Gemini CLI transcript from disk.
2206    pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2207        Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2208    }
2209
2210    /// Parse Gemini CLI's line-oriented session format.
2211    ///
2212    /// Gemini stores a header without a `type`, followed by `user` and
2213    /// `gemini` records. Function calls are embedded in assistant content
2214    /// parts and function responses in user content parts. Unknown records
2215    /// remain byte-exact in [`Session::raw`] instead of silently entering the
2216    /// replay conversation.
2217    pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2218        let mut meta = SessionMeta::new(SessionSource::Gemini);
2219        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2220        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2221        let mut messages = Vec::new();
2222        let mut parse_error_lines = 0usize;
2223        let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2224
2225        for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2226            let value: Value = match serde_json::from_str(line) {
2227                Ok(value) => value,
2228                Err(_) => {
2229                    parse_error_lines += 1;
2230                    continue;
2231                }
2232            };
2233            let kind = value.get("type").and_then(Value::as_str);
2234            if kind.is_none() {
2235                if meta.session_id.is_none() {
2236                    meta.session_id = value
2237                        .get("sessionId")
2238                        .and_then(Value::as_str)
2239                        .map(str::to_string);
2240                }
2241                for (source, target) in [
2242                    ("projectHash", "gemini_project_hash"),
2243                    ("startTime", "created_at"),
2244                    ("lastUpdated", "updated_at"),
2245                    ("kind", "gemini_session_kind"),
2246                ] {
2247                    if let Some(raw) = value.get(source) {
2248                        meta.lineage.insert(
2249                            target.to_string(),
2250                            raw.as_str()
2251                                .map(str::to_string)
2252                                .unwrap_or_else(|| raw.to_string()),
2253                        );
2254                    }
2255                }
2256                continue;
2257            }
2258            if kind != Some("user") && kind != Some("gemini") {
2259                continue;
2260            }
2261
2262            let timestamp = value.get("timestamp").and_then(Value::as_str);
2263            let model = value.get("model").and_then(Value::as_str);
2264            if let Some(model) = model {
2265                meta.model = Some(model.to_string());
2266            }
2267            let content = value.get("content").unwrap_or(&Value::Null);
2268            let parts = content.as_array();
2269            let text = match content {
2270                Value::String(text) => text.clone(),
2271                Value::Array(parts) => parts
2272                    .iter()
2273                    .filter_map(|part| part.get("text").and_then(Value::as_str))
2274                    .collect::<Vec<_>>()
2275                    .join(" ")
2276                    .trim()
2277                    .to_string(),
2278                _ => String::new(),
2279            };
2280
2281            if kind == Some("gemini") {
2282                let legacy_calls = parts
2283                    .into_iter()
2284                    .flatten()
2285                    .filter_map(|part| part.get("functionCall"));
2286                let native_calls = value
2287                    .get("toolCalls")
2288                    .and_then(Value::as_array)
2289                    .into_iter()
2290                    .flatten();
2291                let calls = native_calls
2292                    .chain(legacy_calls)
2293                    .enumerate()
2294                    .filter_map(|(call_index, call)| {
2295                        let name = call.get("name")?.as_str()?.to_string();
2296                        let id = call
2297                            .get("id")
2298                            .and_then(Value::as_str)
2299                            .map(str::to_string)
2300                            .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2301                        pending_by_name
2302                            .entry(name.clone())
2303                            .or_default()
2304                            .push(id.clone());
2305                        let arguments = call
2306                            .get("args")
2307                            .map(value_to_arg_string)
2308                            .unwrap_or_else(|| "{}".to_string());
2309                        Some(function_call(&id, &name, arguments))
2310                    })
2311                    .collect::<Vec<_>>();
2312                let mut message = ChatMessage {
2313                    role: Role::Assistant,
2314                    content: (!text.is_empty()).then_some(text),
2315                    content_parts: None,
2316                    tool_calls: (!calls.is_empty()).then_some(calls),
2317                    tool_call_id: None,
2318                    name: None,
2319                    metadata: Default::default(),
2320                };
2321                if let Some(timestamp) = timestamp {
2322                    message
2323                        .metadata
2324                        .insert("timestamp".into(), timestamp.into());
2325                }
2326                if let Some(model) = model {
2327                    message.metadata.insert("gemini_model".into(), model.into());
2328                }
2329                if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2330                    message
2331                        .metadata
2332                        .insert("gemini_thoughts".into(), thoughts.to_string());
2333                }
2334                restore_gemini_message_extension(&value, &mut message);
2335                if message.content.is_some() || message.tool_calls.is_some() {
2336                    messages.push(message);
2337                }
2338                continue;
2339            }
2340
2341            let mut user_parts = Vec::new();
2342            if let Some(parts) = parts {
2343                for part in parts {
2344                    if let Some(response) = part.get("functionResponse") {
2345                        push_gemini_user_parts(
2346                            &mut messages,
2347                            std::mem::take(&mut user_parts),
2348                            timestamp,
2349                            &value,
2350                        );
2351                        let name = response
2352                            .get("name")
2353                            .and_then(Value::as_str)
2354                            .unwrap_or("tool")
2355                            .to_string();
2356                        let explicit_id = response
2357                            .get("id")
2358                            .and_then(Value::as_str)
2359                            .map(str::to_string);
2360                        if let Some(id) = explicit_id.as_deref() {
2361                            if let Some(ids) = pending_by_name.get_mut(&name) {
2362                                if let Some(position) = ids.iter().position(|pending| pending == id)
2363                                {
2364                                    ids.remove(position);
2365                                }
2366                            }
2367                        }
2368                        let id = explicit_id
2369                            .or_else(|| {
2370                                pending_by_name
2371                                    .get_mut(&name)
2372                                    .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2373                            })
2374                            .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2375                        let output = response
2376                            .get("response")
2377                            .and_then(|response| response.get("output"))
2378                            .map(|output| {
2379                                output
2380                                    .as_str()
2381                                    .map(str::to_string)
2382                                    .unwrap_or_else(|| output.to_string())
2383                            })
2384                            .or_else(|| response.get("response").map(Value::to_string))
2385                            .unwrap_or_default();
2386                        let mut message = tool_message(&id, output);
2387                        message.name = Some(name);
2388                        if let Some(timestamp) = timestamp {
2389                            message
2390                                .metadata
2391                                .insert("timestamp".into(), timestamp.into());
2392                        }
2393                        restore_gemini_message_extension(&value, &mut message);
2394                        messages.push(message);
2395                        continue;
2396                    }
2397                    if let Some(text) = part.get("text").and_then(Value::as_str) {
2398                        user_parts.push(serde_json::json!({"type": "text", "text": text}));
2399                        continue;
2400                    }
2401                    if let Some(inline) = part.get("inlineData") {
2402                        let Some(data) = inline.get("data").and_then(Value::as_str) else {
2403                            continue;
2404                        };
2405                        let media_type = inline
2406                            .get("mimeType")
2407                            .and_then(Value::as_str)
2408                            .unwrap_or("application/octet-stream");
2409                        user_parts.push(serde_json::json!({
2410                            "type": "image_url",
2411                            "image_url": {"url": format!("data:{media_type};base64,{data}")},
2412                        }));
2413                    }
2414                }
2415            } else if !text.is_empty() {
2416                user_parts.push(serde_json::json!({"type": "text", "text": text}));
2417            }
2418            push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2419        }
2420
2421        ensure_tool_results_paired(&mut messages);
2422        let imported_message_count = Some(messages.len());
2423        Ok(Session {
2424            meta,
2425            messages,
2426            subagents: Vec::new(),
2427            raw,
2428            raw_trailing_newline,
2429            imported_message_count,
2430            raw_is_verbatim: true,
2431            parse_error_lines,
2432            load_residue: Vec::new(),
2433        })
2434    }
2435
2436    /// Load a Goose session-export JSON document from disk.
2437    pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2438        Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2439    }
2440
2441    /// Parse Goose's official native import/export document.
2442    ///
2443    /// Goose's durable store is SQLite, but its own
2444    /// `_goose/unstable/session/export` and `/session/import` boundary is one
2445    /// JSON object containing a `conversation` array. Unknown native content
2446    /// blocks are retained on the first canonical message in a namespaced
2447    /// portability envelope; unchanged same-format exports replay the exact
2448    /// source bytes.
2449    pub fn from_goose_str(json: &str) -> Result<Session> {
2450        let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2451        let object = document.as_object().ok_or_else(|| {
2452            Error::InvalidSession("Goose session export must be a JSON object".to_string())
2453        })?;
2454        let conversation = object
2455            .get("conversation")
2456            .and_then(Value::as_array)
2457            .ok_or_else(|| {
2458                Error::InvalidSession(
2459                    "Goose session export must contain a conversation array".to_string(),
2460                )
2461            })?;
2462
2463        let mut meta = SessionMeta::new(SessionSource::Goose);
2464        meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2465        meta.cwd = object
2466            .get("working_dir")
2467            .or_else(|| object.get("workingDir"))
2468            .and_then(Value::as_str)
2469            .map(PathBuf::from);
2470        meta.model = object
2471            .get("model_config")
2472            .or_else(|| object.get("modelConfig"))
2473            .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2474            .and_then(Value::as_str)
2475            .map(str::to_string);
2476        for (source, target) in [
2477            ("name", "session_name"),
2478            ("created_at", "created_at"),
2479            ("updated_at", "updated_at"),
2480            ("session_type", "goose_session_type"),
2481            ("goose_mode", "goose_mode"),
2482            ("provider_name", "goose_provider_name"),
2483            ("parent_session_id", "parent_session_id"),
2484        ] {
2485            if let Some(value) = object.get(source) {
2486                meta.lineage.insert(
2487                    target.to_string(),
2488                    value
2489                        .as_str()
2490                        .map(str::to_string)
2491                        .unwrap_or_else(|| value.to_string()),
2492                );
2493            }
2494        }
2495        let mut header = document.clone();
2496        if let Some(header) = header.as_object_mut() {
2497            header.remove("conversation");
2498        }
2499        meta.goose_header = Some(header.clone());
2500
2501        let mut messages = Vec::new();
2502        for (native_index, native) in conversation.iter().enumerate() {
2503            let before = messages.len();
2504            normalize_goose_message(native, native_index, &mut messages);
2505            if let Some(first) = messages.get_mut(before) {
2506                first
2507                    .metadata
2508                    .insert("goose_native_message".to_string(), native.to_string());
2509                first
2510                    .metadata
2511                    .insert("goose_native_index".to_string(), native_index.to_string());
2512                if native_index == 0 {
2513                    first
2514                        .metadata
2515                        .insert("goose_session_header".to_string(), header.to_string());
2516                }
2517                restore_grok_message_extension(native, first);
2518            }
2519            for message in messages.iter_mut().skip(before + 1) {
2520                message
2521                    .metadata
2522                    .insert("goose_native_index".to_string(), native_index.to_string());
2523            }
2524        }
2525        ensure_tool_results_paired(&mut messages);
2526
2527        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2528        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2529        let imported_message_count = Some(messages.len());
2530        Ok(Session {
2531            meta,
2532            messages,
2533            subagents: Vec::new(),
2534            raw,
2535            raw_trailing_newline,
2536            imported_message_count,
2537            raw_is_verbatim: true,
2538            parse_error_lines: 0,
2539            load_residue: Vec::new(),
2540        })
2541    }
2542
2543    /// Load one Goose session directly from its native SQLite store.
2544    ///
2545    /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2546    /// uses Goose's own public export shape, so the ordinary Goose codec is
2547    /// the single normalization boundary for both files and the live store.
2548    pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2549        Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2550    }
2551
2552    /// Bounded Goose store read for transcript UI surfaces. The inner query
2553    /// selects only the newest native rows; the outer query restores their
2554    /// chronological order. Export/continue callers deliberately use the
2555    /// unbounded public loader above.
2556    #[doc(hidden)]
2557    pub fn from_goose_sqlite_display(
2558        db_path: &Path,
2559        session_id: &str,
2560        message_limit: usize,
2561    ) -> Result<Session> {
2562        Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2563    }
2564
2565    fn from_goose_sqlite_with_limit(
2566        db_path: &Path,
2567        session_id: &str,
2568        message_limit: Option<usize>,
2569    ) -> Result<Session> {
2570        let connection = Connection::open_with_flags(
2571            db_path,
2572            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2573        )
2574        .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2575        let mut statement = connection
2576            .prepare(
2577                "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2578                    extension_data, goose_mode, provider_name, model_config_json \
2579             FROM sessions WHERE id = ?1",
2580            )
2581            .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2582        let mut document = statement
2583            .query_row([session_id], |row| {
2584                let extension_data: Option<String> = row.get(6)?;
2585                let model_config: Option<String> = row.get(9)?;
2586                Ok(serde_json::json!({
2587                    "id": row.get::<_, String>(0)?,
2588                    "working_dir": row.get::<_, String>(2)?,
2589                    "name": row.get::<_, String>(1)?,
2590                    "user_set_name": false,
2591                    "session_type": row.get::<_, String>(5)?,
2592                    "created_at": row.get::<_, String>(3)?,
2593                    "updated_at": row.get::<_, String>(4)?,
2594                    "extension_data": extension_data
2595                        .as_deref()
2596                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2597                        .unwrap_or_else(|| serde_json::json!({})),
2598                    "usage": {},
2599                    "accumulated_usage": {},
2600                    "accumulated_cost": Value::Null,
2601                    "schedule_id": Value::Null,
2602                    "recipe": Value::Null,
2603                    "user_recipe_values": Value::Null,
2604                    "conversation": [],
2605                    "message_count": 0,
2606                    "last_message_at": Value::Null,
2607                    "provider_name": row.get::<_, Option<String>>(8)?,
2608                    "model_config": model_config
2609                        .as_deref()
2610                        .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2611                    "goose_mode": row.get::<_, String>(7)?,
2612                    "archived_at": Value::Null,
2613                    "project_id": Value::Null,
2614                    "parent_session_id": Value::Null,
2615                    "last_message_snippet": Value::Null,
2616                }))
2617            })
2618            .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2619
2620        let message_query = message_limit.map_or_else(
2621            || {
2622                "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2623                 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2624                    .to_string()
2625            },
2626            |limit| {
2627                format!(
2628                    "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2629                     FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2630                                  created_timestamp, metadata_json \
2631                           FROM messages WHERE session_id = ?1 \
2632                           ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2633                     ORDER BY created_timestamp, native_row_id"
2634                )
2635            },
2636        );
2637        let mut message_statement = connection
2638            .prepare(&message_query)
2639            .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2640        let rows = message_statement
2641            .query_map([session_id], |row| {
2642                let content: String = row.get(2)?;
2643                let metadata: Option<String> = row.get(4)?;
2644                Ok(serde_json::json!({
2645                    "id": row.get::<_, Option<String>>(0)?,
2646                    "role": row.get::<_, String>(1)?,
2647                    "created": row.get::<_, i64>(3)?,
2648                    "content": serde_json::from_str::<Value>(&content)
2649                        .unwrap_or_else(|_| Value::Array(Vec::new())),
2650                    "metadata": metadata
2651                        .as_deref()
2652                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2653                        .unwrap_or_else(|| serde_json::json!({
2654                            "userVisible": true,
2655                            "agentVisible": true
2656                        })),
2657                }))
2658            })
2659            .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2660        let conversation = rows
2661            .collect::<std::result::Result<Vec<_>, _>>()
2662            .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2663        document["message_count"] = Value::from(conversation.len());
2664        document["conversation"] = Value::Array(conversation);
2665        let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2666        let mut session = Self::from_goose_str(&json)?;
2667        // SQLite was reconstructed through values, not captured byte-for-byte.
2668        session.raw_is_verbatim = false;
2669        Ok(session)
2670    }
2671
2672    /// Load an OpenCode session from a file — either read surface, see
2673    /// [`Self::from_opencode_str`].
2674    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2675        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2676    }
2677
2678    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2679    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2680    /// most-recently-updated top-level session, see
2681    /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2682    /// envelope form [`Self::from_opencode_str`] already parses for the
2683    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2684    /// discipline, S1 tool-output masking, …) is shared code, not
2685    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2686    /// for the envelope-construction rules this follows (all-columns rule,
2687    /// raw `revert` column carried verbatim).
2688    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2689        let conn = opencode_sqlite_open(db_path)?;
2690        let id = match session_id {
2691            Some(id) => id.to_string(),
2692            None => opencode_sqlite_primary_session_id(&conn)?,
2693        };
2694        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2695        let mut text = lines.join("\n");
2696        text.push('\n');
2697        let mut session = Self::from_opencode_str(&text)?;
2698        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2699        // not the original source bytes (a binary `.db` file has no
2700        // "verbatim" line-oriented form to begin with). `from_opencode_str`
2701        // defaults `raw_is_verbatim` to `true` because for its OTHER two
2702        // callers (an actual envelope-form file's own text, an actual
2703        // export-document's text) that really is the source. It is NEVER
2704        // true for this diagonal — mirrors the export-document fix just
2705        // above for the same reason (`from_opencode_export_doc`, `false`).
2706        // `convert opencode.db --to opencode` must not claim byte-identical.
2707        session.raw_is_verbatim = false;
2708        Ok(session)
2709    }
2710
2711    /// Parse an OpenCode session from either of its two frozen **read
2712    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2713    /// `opencode-fields.md`):
2714    ///
2715    /// - the **envelope form**: each line is
2716    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
2717    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
2718    ///   generations;
2719    /// - the **export-document form**: a single pretty-printed JSON document
2720    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2721    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
2722    ///   what the OpenCode writer emits.
2723    ///
2724    /// Both forms are parsed into the same `(session_info, side_records,
2725    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2726    /// `opencode_session_from_records` — so the same underlying records
2727    /// produce identical `messages` regardless of which surface carried
2728    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2729    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2730    /// exercises): previously this function parsed the envelope form only
2731    /// and silently returned an empty-but-`Ok` `Session` for an export
2732    /// document — the confirmed footgun this now closes.
2733    ///
2734    /// Record classification (envelope form) is driven by the envelope
2735    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2736    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2737    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2738    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2739    /// every column, `data` and non-`data` alike — e.g. the `session` row's
2740    /// `revert` column under the V2 `Revert.State` schema, whose extra
2741    /// `files` field the CLI's own row→V1 reconstruction drops; the
2742    /// envelope's `raw` capture keeps that raw column value regardless of
2743    /// what this loader's canonicalization understands).
2744    ///
2745    /// Mapping to canonical `messages` (§2.1, shared by both forms via
2746    /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2747    /// text parts → `content`; a `User` `file` part whose `mime` is an image
2748    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2749    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2750    /// `ToolCall`, and the SAME part's `state.completed.output` /
2751    /// `state.error.error` → a paired `Tool` message split by `callID`
2752    /// (opencode keeps call+result on one record; this loader splits it
2753    /// into the two OpenAI-shape messages the other loaders already
2754    /// produce).
2755    ///
2756    /// **S1 (`time.compacted`):** when a `tool` part's
2757    /// `state.completed.time.compacted` is set, the emitted `Tool`
2758    /// message's `content` is the placeholder
2759    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2760    /// own `toModelMessage` replays — while the REAL output survives in
2761    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2762    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2763    /// it is reversible, never actually lost.
2764    ///
2765    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2766    /// every message strictly before that message id
2767    /// `metadata["compacted_out"]="true"` (honored uniformly by
2768    /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2769    /// message, which opencode itself hoists in FRONT of the retained tail
2770    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2771    /// regardless of its position, mirroring pi's identical exemption for
2772    /// its own compaction/branch-summary entries.
2773    ///
2774    /// **Unknown part `type` or unknown `tool.state.status`:** never
2775    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2776    /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2777    /// is what turns that into a visible coverage failure rather than a
2778    /// silent drop.
2779    ///
2780    /// **Export-document `raw`:** an export document is a single
2781    /// pretty-printed JSON value with no per-line envelope structure of its
2782    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2783    /// envelope line per `session`/`message`/`part` record found in the
2784    /// document, in the exact `{"key":[...],"value":...}` shape the native
2785    /// envelope form uses — so every native/T1-value-tier path
2786    /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2787    /// splice/direct-write writers) stays consistent regardless of which
2788    /// read surface produced this `Session`.
2789    ///
2790    /// **Malformed input:** input that reaches this function non-empty but
2791    /// yields zero session/message/part records under EITHER form returns a
2792    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2793    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2794    /// input must not silently succeed with an empty session). A
2795    /// legitimately-empty session — a real `session` record with zero
2796    /// messages, or a valid export document with an empty `messages` array
2797    /// — is not an error.
2798    pub fn from_opencode_str(text: &str) -> Result<Session> {
2799        let trimmed = text.trim();
2800
2801        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2802        // own precedence: try the whole-text parse before the per-line
2803        // envelope loop below, since a pretty-printed multi-line document
2804        // has no individually-valid-JSON lines for that loop to match.
2805        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2806            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2807            {
2808                return Self::from_opencode_export_doc(&doc);
2809            }
2810        }
2811
2812        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2813        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2814        // blank-skipping PARSE walk just below, which keeps skipping
2815        // blank/whitespace-only lines when it looks for envelope records.
2816        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2817        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2818        let mut session_info: Option<Value> = None;
2819        let mut side_records: Vec<Value> = Vec::new();
2820        let mut msgs: Vec<OcMsg> = Vec::new();
2821        let mut msg_index: HashMap<String, usize> = HashMap::new();
2822        // PARITY-15: see `from_claude_code_str`'s identical counter — only
2823        // a genuinely malformed line (fails to deserialize as JSON at all),
2824        // not a well-formed envelope this loader simply doesn't recognize.
2825        let mut parse_error_lines = 0usize;
2826
2827        for line in non_empty_lines(text) {
2828            let Ok(env) = serde_json::from_str::<Value>(line) else {
2829                parse_error_lines += 1;
2830                continue; // malformed line — raw-only, exactly like the other loaders
2831            };
2832            let Some(key) = env.get("key").and_then(Value::as_array) else {
2833                continue; // not an envelope record — raw-only
2834            };
2835            let value = env.get("value").cloned().unwrap_or(Value::Null);
2836            match key.first().and_then(Value::as_str) {
2837                Some("session") => session_info = Some(value),
2838                Some("message") => {
2839                    let Some(id) = value.get("id").and_then(Value::as_str) else {
2840                        continue;
2841                    };
2842                    let time_created = value
2843                        .get("time")
2844                        .and_then(|t| t.get("created"))
2845                        .and_then(Value::as_i64)
2846                        .unwrap_or(0);
2847                    msg_index.insert(id.to_string(), msgs.len());
2848                    msgs.push(OcMsg {
2849                        id: id.to_string(),
2850                        time_created,
2851                        value,
2852                        parts: Vec::new(),
2853                    });
2854                }
2855                Some("part") => {
2856                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2857                        if let Some(&idx) = msg_index.get(msg_id) {
2858                            msgs[idx].parts.push(value);
2859                        }
2860                        // A part whose message wasn't captured (out-of-order
2861                        // envelope) — still fully present in `raw`, just not
2862                        // attached to a canonical message.
2863                    }
2864                }
2865                Some("session_diff") | Some("todo") => {
2866                    side_records.push(serde_json::json!({"key": key, "value": value}));
2867                }
2868                _ => {} // unrecognized top-level key — raw-only
2869            }
2870        }
2871
2872        opencode_guard_against_silent_empty(
2873            !trimmed.is_empty(),
2874            &session_info,
2875            &msgs,
2876            &side_records,
2877        )?;
2878        opencode_session_from_records(
2879            session_info,
2880            side_records,
2881            msgs,
2882            raw,
2883            raw_trailing_newline,
2884            // Envelope form: `raw` is split directly out of the source text
2885            // (strict-verbatim, IX-1) — genuinely reproduces the original
2886            // bytes on replay.
2887            true,
2888            parse_error_lines,
2889        )
2890    }
2891
2892    /// The **export-document** read surface of [`Self::from_opencode_str`]
2893    /// — see that function's doc comment for the shared canonicalization
2894    /// and the `raw` re-synthesis this performs. `doc` is already known to
2895    /// have the `{info, messages:[...]}` shape (the caller checks this,
2896    /// matching `detect_source`'s own S9a check) before calling this.
2897    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2898        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2899        let messages_arr = doc
2900            .get("messages")
2901            .and_then(Value::as_array)
2902            .cloned()
2903            .unwrap_or_default();
2904
2905        let session_id = session_info
2906            .as_ref()
2907            .and_then(|si| si.get("id"))
2908            .and_then(Value::as_str)
2909            .unwrap_or("ses_unknown")
2910            .to_string();
2911        let project_id = session_info
2912            .as_ref()
2913            .and_then(|si| si.get("projectID"))
2914            .and_then(Value::as_str)
2915            .unwrap_or("global")
2916            .to_string();
2917
2918        // Re-synthesize one envelope line per record — see the doc comment
2919        // on `from_opencode_str` ("Export-document `raw`").
2920        let mut raw: Vec<String> = Vec::new();
2921        if let Some(si) = &session_info {
2922            raw.push(
2923                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2924                    .to_string(),
2925            );
2926        }
2927
2928        let mut msgs: Vec<OcMsg> = Vec::new();
2929        for entry in &messages_arr {
2930            let Some(info) = entry.get("info") else {
2931                continue; // malformed message entry — no clean home, raw-only
2932            };
2933            let Some(id) = info.get("id").and_then(Value::as_str) else {
2934                continue;
2935            };
2936            let time_created = info
2937                .get("time")
2938                .and_then(|t| t.get("created"))
2939                .and_then(Value::as_i64)
2940                .unwrap_or(0);
2941            let parts: Vec<Value> = entry
2942                .get("parts")
2943                .and_then(Value::as_array)
2944                .cloned()
2945                .unwrap_or_default();
2946
2947            raw.push(
2948                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2949            );
2950            for p in &parts {
2951                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
2952                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
2953            }
2954
2955            msgs.push(OcMsg {
2956                id: id.to_string(),
2957                time_created,
2958                value: info.clone(),
2959                parts,
2960            });
2961        }
2962
2963        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
2964        opencode_session_from_records(
2965            session_info,
2966            Vec::new(),
2967            msgs,
2968            raw,
2969            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
2970            // line re-derived per record, no real per-line source bytes to
2971            // measure) — matches the historical always-newline-terminated
2972            // behavior; see `Session::raw_trailing_newline`'s doc comment.
2973            true,
2974            // Export-document form: `raw` above is RE-SYNTHESIZED, one
2975            // envelope line derived per record — not the original document's
2976            // bytes (see this function's doc comment). `convert`'s
2977            // byte-identical claim must not fire on this diagonal.
2978            false,
2979            // PARITY-15: a pretty-printed export document is parsed WHOLE
2980            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
2981            // there's no per-line parse-loss concept here; a malformed
2982            // document fails that top-level parse and never reaches this
2983            // function at all.
2984            0,
2985        )
2986    }
2987
2988    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
2989    /// core.session(tree-addressable transcript)"): materialize this
2990    /// session's linear [`Self::messages`] into a native in-place
2991    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
2992    /// FIRST time it wants to run a tree operation (rewind/branch/label)
2993    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
2994    /// synthesized node (see
2995    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
2996    /// why a single timestamp is used: the source linear messages carry no
2997    /// per-turn timestamp of their own here).
2998    ///
2999    /// This does not mutate `self` or persist anything — see
3000    /// the composition layer's session-store tree writer for persistence, and
3001    /// [`Self::apply_session_tree`] for the inverse bridge.
3002    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
3003        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
3004    }
3005
3006    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
3007    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
3008    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3009    /// existing linear consumer — the agent loop, exporters — working
3010    /// unchanged after a tree operation runs). Nothing else on `self`
3011    /// (`meta`, `raw`, ...) is touched.
3012    ///
3013    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3014    /// `Err` rather than applying anything — a structurally-corrupt tree
3015    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3016    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3017    /// `self` is left untouched on `Err` (the assignment only happens after
3018    /// the projection has already succeeded).
3019    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3020        self.messages = tree.linear_projection()?;
3021        Ok(())
3022    }
3023}
3024
3025/// One opencode `message` record plus its `part` children, gathered from
3026/// EITHER read surface (envelope-form records or export-document
3027/// `{info, parts}` entries) before the shared per-record canonicalization
3028/// in [`opencode_session_from_records`].
3029struct OcMsg {
3030    id: String,
3031    time_created: i64,
3032    value: Value,
3033    parts: Vec<Value>,
3034}
3035
3036const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3037const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3038const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3039
3040/// Guard against the confirmed footgun: input that reached
3041/// [`Session::from_opencode_str`] non-empty but produced no
3042/// session/message/part record under either read surface returns `Err`
3043/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3044/// (a real session record with zero messages, or a valid empty `messages`
3045/// array) is not an error — only genuinely unparseable content is.
3046fn opencode_guard_against_silent_empty(
3047    non_empty_input: bool,
3048    session_info: &Option<Value>,
3049    msgs: &[OcMsg],
3050    side_records: &[Value],
3051) -> Result<()> {
3052    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3053        || !msgs.is_empty()
3054        || !side_records.is_empty();
3055    if non_empty_input && !has_any_record {
3056        return Err(crate::Error::Other(
3057            "opencode input was recognized as an OpenCode source (envelope or \
3058             export-document form) but no session/message/part record could be parsed from \
3059             it — refusing to silently return an empty session"
3060                .to_string(),
3061        ));
3062    }
3063    Ok(())
3064}
3065
3066/// The shared per-record canonicalization for BOTH of
3067/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3068/// export-document form): frozen ordering, `SessionMeta` capture, the
3069/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3070/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3071/// same underlying `(session_info, side_records, msgs)` regardless of which
3072/// surface produced them, this produces byte-for-byte identical `messages`
3073/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3074fn opencode_session_from_records(
3075    session_info: Option<Value>,
3076    side_records: Vec<Value>,
3077    mut msgs: Vec<OcMsg>,
3078    raw: Vec<String>,
3079    raw_trailing_newline: bool,
3080    raw_is_verbatim: bool,
3081    parse_error_lines: usize,
3082) -> Result<Session> {
3083    let mut meta = SessionMeta::new(SessionSource::OpenCode);
3084
3085    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3086    // each message id to its PRE-sort position — used only to resolve a
3087    // `tail_start_id` reference in the compaction-boundary pass further
3088    // down. In every real opencode session (either surface) records
3089    // already arrive/are listed in creation order, so pre- and post-sort
3090    // positions coincide; this mirrors the original envelope-only
3091    // implementation's behavior exactly (not a new invariant introduced by
3092    // sharing this code across both surfaces).
3093    let msg_index: HashMap<String, usize> = msgs
3094        .iter()
3095        .enumerate()
3096        .map(|(i, m)| (m.id.clone(), i))
3097        .collect();
3098
3099    // Frozen order (§1.2): messages by (time.created, id); each
3100    // message's parts by id.
3101    msgs.sort_by(|a, b| {
3102        a.time_created
3103            .cmp(&b.time_created)
3104            .then_with(|| a.id.cmp(&b.id))
3105    });
3106    for m in &mut msgs {
3107        m.parts.sort_by(|a, b| {
3108            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3109            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3110            ai.cmp(bi)
3111        });
3112    }
3113
3114    meta.opencode_headers
3115        .push(session_info.clone().unwrap_or(Value::Null));
3116    meta.opencode_headers.extend(side_records);
3117    if let Some(si) = &session_info {
3118        capture_opencode_session_info(si, &mut meta)?;
3119    }
3120
3121    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3122    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3123    // active path in opencode's own linear message list, so no branch
3124    // walk is needed the way pi's tree requires).
3125    let mut tail_start_pos: Option<usize> = None;
3126    for m in &msgs {
3127        for p in &m.parts {
3128            if p.get("type").and_then(Value::as_str) == Some("compaction") {
3129                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3130                    if let Some(&tp) = msg_index.get(t) {
3131                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3132                    }
3133                }
3134            }
3135        }
3136    }
3137
3138    let mut messages = Vec::new();
3139    let mut first_system_seen = false;
3140    for (pos, m) in msgs.iter().enumerate() {
3141        let before = messages.len();
3142        match m.value.get("role").and_then(Value::as_str) {
3143            // B4: a `User` message that's actually
3144            // `append_synthesized_opencode_messages`'s own re-materialized
3145            // Claude `system` record (one `synthetic: true` text part
3146            // carrying the supercode marker key — see
3147            // `opencode_claude_system_subtype`'s doc comment) restores
3148            // `Role::System`, not a genuine user turn.
3149            Some("user") => match opencode_claude_system_subtype(&m.parts) {
3150                Some(subtype) => {
3151                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3152                }
3153                None => push_opencode_user(
3154                    &m.value,
3155                    &m.parts,
3156                    &mut messages,
3157                    &mut meta,
3158                    &mut first_system_seen,
3159                ),
3160            },
3161            Some("assistant") => {
3162                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3163            }
3164            // Unrecognized/missing role — raw-only survival;
3165            // `audit::Corpus::OpenCode` scores this as Unmodeled.
3166            _ => {}
3167        }
3168        if let Some(original_position) = m
3169            .value
3170            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3171            .and_then(Value::as_u64)
3172        {
3173            if let Some(message) = messages[before..]
3174                .iter_mut()
3175                .find(|message| message.role != Role::Tool)
3176            {
3177                message.metadata.insert(
3178                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3179                    original_position.to_string(),
3180                );
3181            }
3182        }
3183        for msg in &mut messages[before..] {
3184            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3185            if !is_summary {
3186                if let Some(tsp) = tail_start_pos {
3187                    if pos < tsp {
3188                        msg.metadata
3189                            .insert("compacted_out".to_string(), "true".to_string());
3190                    }
3191                }
3192            }
3193        }
3194    }
3195
3196    let marked_slots = messages
3197        .iter()
3198        .enumerate()
3199        .filter_map(|(index, message)| {
3200            message
3201                .metadata
3202                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3203                .then_some(index)
3204        })
3205        .collect::<Vec<_>>();
3206    if !marked_slots.is_empty() {
3207        // A spliced OpenCode export can contain an unmarked native prefix
3208        // followed by a marked synthesized tail. Reorder only among the
3209        // marked slots so the tail never jumps in front of its raw prefix.
3210        let mut marked_messages = marked_slots
3211            .iter()
3212            .map(|index| messages[*index].clone())
3213            .collect::<Vec<_>>();
3214        marked_messages.sort_by_key(|message| {
3215            message
3216                .metadata
3217                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3218                .and_then(|position| position.parse::<usize>().ok())
3219                .unwrap_or(usize::MAX)
3220        });
3221        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3222            messages[slot] = message;
3223        }
3224        for message in &mut messages {
3225            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3226        }
3227    }
3228    ensure_tool_results_paired(&mut messages);
3229    let imported_message_count = Some(messages.len());
3230    Ok(Session {
3231        meta,
3232        messages,
3233        subagents: Vec::new(),
3234        raw,
3235        raw_trailing_newline,
3236        imported_message_count,
3237        raw_is_verbatim,
3238        parse_error_lines,
3239        load_residue: Vec::new(),
3240    })
3241}
3242
3243/// Resolve each opencode subagent (`task`) child session's
3244/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3245/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3246/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3247/// `opencode-fields.md` `task.ts:145,171-176`).
3248///
3249/// Nesting itself needs no opencode-specific pass:
3250/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3251/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3252/// so the existing generic [`Session::reconstruct_tree`] nests these
3253/// sessions correctly on its own. Call this FIRST — it only reads
3254/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3255/// the same `Vec` to `reconstruct_tree`.
3256pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3257    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3258    for i in 0..sessions.len() {
3259        let child_id = sessions[i].meta.session_id.clone();
3260        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3261        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3262            continue;
3263        };
3264        let Some(parent_idx) = ids
3265            .iter()
3266            .position(|id| id.as_deref() == Some(parent_id.as_str()))
3267        else {
3268            continue;
3269        };
3270        for m in &sessions[parent_idx].messages {
3271            for (k, v) in &m.metadata {
3272                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3273                    if v == &child_id {
3274                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3275                    }
3276                }
3277            }
3278        }
3279    }
3280}
3281
3282/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3283///
3284/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3285/// structure but are NOT guaranteed to be well-formed in raw file order: async
3286/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3287/// line BEFORE the assistant `tool_use` line that owns it, even though the
3288/// parent/child tree itself is fine. The active-branch projection restores
3289/// parent-before-child order, but a result can still trail a later assistant
3290/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3291/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3292/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3293///
3294/// This reorders `messages` so every OWNED `Role::Tool` result (its
3295/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3296/// message anywhere in the list) sits immediately after the `Role::Assistant`
3297/// message that owns it, while leaving every other message's relative order
3298/// untouched. Orphan tool results — no matching call anywhere in the list —
3299/// are left in their ORIGINAL position, untouched; they are never moved. It
3300/// is a pure reorder: same message count, same multiset of messages, in/out.
3301///
3302/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3303/// — each appears exactly once as a call and once as its result — so a
3304/// simple id -> owning-assistant map is sufficient; no special-casing is
3305/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3306/// already pushes those inline with their own distinct ids.
3307///
3308/// Results whose matching call is missing entirely (no owner found) are left
3309/// in place untouched — `ensure_tool_results_paired` (which runs right after
3310/// this) is responsible for synthesizing a placeholder result for any call
3311/// that ends up unanswered; this pass never drops or fabricates anything.
3312fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3313    // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3314    //    some assistant message anywhere in the list — and the position of
3315    //    that owning assistant. Owned as `String` (not borrowed) so this map
3316    //    can outlive the later `messages.drain(..)`.
3317    let mut owner_positions: HashMap<String, usize> = HashMap::new();
3318    for (index, m) in messages.iter().enumerate() {
3319        if m.role == Role::Assistant {
3320            for c in m.tool_calls() {
3321                if !c.id.is_empty() {
3322                    owner_positions.entry(c.id.clone()).or_insert(index);
3323                }
3324            }
3325        }
3326    }
3327
3328    // Fast, cheap detection of "nothing to do": every owned result must be
3329    // in the contiguous tool-result block immediately following its owning
3330    // assistant. Checking only result-before-owner inversions is insufficient
3331    // after Claude's active-branch projection: that projection can put the
3332    // owner first while leaving its result behind a later assistant turn.
3333    // Mere orphans never set this flag. A canonical session returns with
3334    // `messages` byte-for-byte unchanged, mirroring
3335    // `ensure_tool_results_paired`'s own no-op guard.
3336    let mut contiguous_owner = None;
3337    let needs_reorder =
3338        messages
3339            .iter()
3340            .enumerate()
3341            .any(|(message_index, message)| match message.role {
3342                Role::Assistant => {
3343                    contiguous_owner = Some(message_index);
3344                    false
3345                }
3346                Role::Tool => match message
3347                    .tool_call_id
3348                    .as_deref()
3349                    .and_then(|id| owner_positions.get(id))
3350                    .copied()
3351                {
3352                    Some(owner) => Some(owner) != contiguous_owner,
3353                    None => {
3354                        // An orphan or unlinked tool message interrupts the
3355                        // owner's contiguous result block but never moves by
3356                        // itself.
3357                        contiguous_owner = None;
3358                        false
3359                    }
3360                },
3361                _ => {
3362                    contiguous_owner = None;
3363                    false
3364                }
3365            });
3366    if !needs_reorder {
3367        return;
3368    }
3369
3370    // 1. Second pass: route messages into the "spine" (everything that stays
3371    //    at its own position — non-tool messages AND orphan tool results)
3372    //    versus owned tool results (pulled out, to be reattached right after
3373    //    their owner). Record, for each spine index that's an assistant, the
3374    //    set of tool_call_ids it owns.
3375    let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3376    let mut call_owner: HashMap<String, usize> = HashMap::new();
3377    // Buffer of (original_position, message) for every OWNED tool result,
3378    // built alongside the spine; a result can reference a call emitted later
3379    // in file order, so owner spine-index is resolved in a later step once
3380    // `call_owner` is complete.
3381    let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3382
3383    let drained: Vec<ChatMessage> = std::mem::take(messages);
3384    for (orig_pos, msg) in drained.into_iter().enumerate() {
3385        if msg.role == Role::Tool {
3386            let is_owned = msg
3387                .tool_call_id
3388                .as_deref()
3389                .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3390                .unwrap_or(false);
3391            if is_owned {
3392                owned_results.push((orig_pos, msg));
3393                continue;
3394            }
3395            // Orphan: no matching call anywhere. Treat exactly like a
3396            // non-tool message for placement — it joins the spine at its
3397            // current position and is never moved.
3398            spine.push(msg);
3399            continue;
3400        }
3401        if msg.role == Role::Assistant {
3402            let spine_idx = spine.len();
3403            for c in msg.tool_calls() {
3404                if !c.id.is_empty() {
3405                    call_owner.entry(c.id.clone()).or_insert(spine_idx);
3406                }
3407            }
3408        }
3409        spine.push(msg);
3410    }
3411
3412    // 2. Resolve each owned result's owner spine-index now that `call_owner`
3413    //    is complete, then bucket results by owner spine-index. Every result
3414    //    here was routed as "owned" because its id was found in `owned_ids`,
3415    //    which was built from the exact same `tool_calls()` scan that
3416    //    populates `call_owner` below, so the lookup is guaranteed to hit.
3417    let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3418    for (orig_pos, msg) in owned_results.into_iter() {
3419        let id = msg
3420            .tool_call_id
3421            .as_deref()
3422            .filter(|id| !id.is_empty())
3423            .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3424        let idx = *call_owner
3425            .get(id)
3426            .expect("owned id must have an owning assistant in call_owner");
3427        buckets.entry(idx).or_default().push((orig_pos, msg));
3428    }
3429    // Keep each bucket's results in their original relative file order.
3430    for v in buckets.values_mut() {
3431        v.sort_by_key(|(pos, _)| *pos);
3432    }
3433
3434    // 3. Rebuild: emit each spine message (which now includes orphans at
3435    //    their original position, untouched) in order; immediately after
3436    //    emitting an assistant message that owns one or more tool results,
3437    //    emit its owned results, in original relative order.
3438    let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3439    for (idx, msg) in spine.into_iter().enumerate() {
3440        out.push(msg);
3441        if let Some(results) = buckets.remove(&idx) {
3442            for (_, r) in results {
3443                out.push(r);
3444            }
3445        }
3446    }
3447    *messages = out;
3448}
3449
3450/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3451/// result. Interrupted/aborted turns leave a tool call with no result, which
3452/// many chat-completions endpoints reject when the conversation is replayed.
3453/// We insert a synthetic placeholder result immediately after the assistant
3454/// turn so the transcript stays valid for continuation. (Orphan results — a
3455/// tool message with no preceding call — do not occur in practice and are left
3456/// untouched.)
3457fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3458    let answered: HashSet<String> = messages
3459        .iter()
3460        .filter(|m| m.role == Role::Tool)
3461        .filter_map(|m| m.tool_call_id.clone())
3462        .collect();
3463
3464    // Nothing missing? Leave the vector byte-for-byte unchanged.
3465    let any_missing = messages.iter().any(|m| {
3466        m.tool_calls()
3467            .iter()
3468            .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3469    });
3470    if !any_missing {
3471        return;
3472    }
3473
3474    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3475    for msg in messages.drain(..) {
3476        let synth: Vec<ChatMessage> = msg
3477            .tool_calls()
3478            .iter()
3479            .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3480            .map(|c| {
3481                let mut m = ChatMessage::tool_result(
3482                    c.id.clone(),
3483                    c.function.name.clone(),
3484                    "[no tool result recorded — turn interrupted]".to_string(),
3485                );
3486                // TR-10: an interrupted call never executed to completion —
3487                // never a candidate for `ReductionKind::ToolInputElided`
3488                // (the "still-pending calls are never input-elided"
3489                // boundary).
3490                crate::mark_tool_error(&mut m);
3491                m
3492            })
3493            .collect();
3494        out.push(msg);
3495        out.extend(synth);
3496    }
3497    *messages = out;
3498}
3499
3500/// Whether `msg` is excluded from every replay/export path — the frozen
3501/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3502/// a message marked `compacted_out` (pre-compaction history a source harness
3503/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3504/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3505/// format, not just the one that produced the marker — so a translated
3506/// compacted session replays the same sliced context the source harness
3507/// would, instead of double-including history plus its own summary.
3508fn is_replay_excluded(msg: &ChatMessage) -> bool {
3509    msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3510        || msg
3511            .metadata
3512            .get("pi_exclude_from_context")
3513            .map(String::as_str)
3514            == Some("true")
3515}
3516
3517// ---- detection ------------------------------------------------------------
3518
3519fn detect_source(text: &str) -> Option<SessionSource> {
3520    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3521    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3522    // pretty-printed, MULTI-LINE JSON document, unlike every other format
3523    // this crate reads. It cannot be recognized by the per-line loop below
3524    // (no individual line of a pretty-printed document is itself valid
3525    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3526    // attempt: a real JSONL file (many newline-separated objects) fails this
3527    // parse immediately (trailing-data error) and falls through unaffected.
3528    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3529        if v.get("conversation").and_then(Value::as_array).is_some()
3530            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3531        {
3532            return Some(SessionSource::Goose);
3533        }
3534        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3535            return Some(SessionSource::OpenCode);
3536        }
3537    }
3538    for line in non_empty_lines(text) {
3539        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3540        // than abandoning detection — the loaders themselves skip bad lines, so
3541        // bailing here would silently misroute an otherwise-valid Codex file.
3542        let Ok(v) = serde_json::from_str::<Value>(line) else {
3543            continue;
3544        };
3545        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3546        // one record per line — the synthesized raw-capture unit for the
3547        // JSON-tree/SQLite generations alike. No other format's lines carry
3548        // both a top-level `key` ARRAY and a `value` field, so this is
3549        // unambiguous against Codex/Pi/Claude Code.
3550        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3551            return Some(SessionSource::OpenCode);
3552        }
3553        // Codex envelopes always carry a `payload`; Claude Code lines never do.
3554        if v.get("payload").is_some() {
3555            return Some(SessionSource::Codex);
3556        }
3557        // Gemini CLI starts with an untyped session header. Its project hash
3558        // and timestamps distinguish it from Claude Code records that also
3559        // carry `sessionId`.
3560        if v.get("sessionId").and_then(Value::as_str).is_some()
3561            && (v.get("projectHash").is_some()
3562                || v.get("startTime").is_some()
3563                || v.get("lastUpdated").is_some())
3564            && v.get("type").is_none()
3565        {
3566            return Some(SessionSource::Gemini);
3567        }
3568        // Grok's resumable `chat_history.jsonl` stores the role/type and
3569        // content directly on each record. Claude Code uses a nested
3570        // `message` envelope for the overlapping `user`/`assistant` tags.
3571        let tag = v.get("type").and_then(Value::as_str);
3572        if tag == Some("gemini") && v.get("content").is_some() {
3573            return Some(SessionSource::Gemini);
3574        }
3575        if v.get("message").is_none()
3576            && v.get("uuid").is_none()
3577            && v.get("sessionId").is_none()
3578            && matches!(
3579                tag,
3580                Some(
3581                    "system"
3582                        | "user"
3583                        | "assistant"
3584                        | "tool_result"
3585                        | "reasoning"
3586                        | "backend_tool_call"
3587                )
3588            )
3589            && (v.get("content").is_some()
3590                || v.get("tool_calls").is_some()
3591                || v.get("tool_call_id").is_some()
3592                || v.get("encrypted_content").is_some()
3593                || v.get("kind").is_some())
3594        {
3595            return Some(SessionSource::Grok);
3596        }
3597        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3598        // (the session id) with no `message`/`uuid` — Claude Code's own
3599        // `type`-bearing lines always carry one or the other, never a
3600        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3601        // §1).
3602        if v.get("type").and_then(Value::as_str) == Some("session")
3603            && v.get("id").and_then(Value::as_str).is_some()
3604            && v.get("message").is_none()
3605            && v.get("uuid").is_none()
3606        {
3607            return Some(SessionSource::Pi);
3608        }
3609        if v.get("type").is_some() || v.get("message").is_some() {
3610            return Some(SessionSource::ClaudeCode);
3611        }
3612    }
3613    None
3614}
3615
3616/// Which on-disk OpenCode storage surface is present under a data root
3617/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3618/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3619/// generation A. This is a **filesystem classifier only** — it answers
3620/// "which generation is this?" for a corpus-discovery tool; it does not
3621/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3622/// for the envelope form any of these three surfaces synthesizes into, and
3623/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3624/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3625/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3626/// round-trips via the JSON store per upstream's own behavior even on a
3627/// SQLite install, so nothing is silently lost by not reading the legacy
3628/// trees directly).
3629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3630pub enum OpenCodeStorageSurface {
3631    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3632    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3633    /// [`opencode_sqlite_corpus_envelope_text`].
3634    Sqlite,
3635    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3636    /// marker file `storage/migration`.
3637    JsonTreeB,
3638    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3639    JsonTreeA,
3640}
3641
3642/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3643/// storage surface present, per the discovery rules frozen in
3644/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3645/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3646/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3647/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3648/// `project/` subtree. Returns `None` if nothing is found.
3649pub fn detect_opencode_storage_surface(
3650    data_root: &Path,
3651) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3652    if let Ok(p) = std::env::var("OPENCODE_DB") {
3653        let pb = PathBuf::from(p);
3654        if pb.is_file() {
3655            return Some((OpenCodeStorageSurface::Sqlite, pb));
3656        }
3657    }
3658    if let Ok(entries) = std::fs::read_dir(data_root) {
3659        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3660        // NOT deterministic — a store with both a default-channel
3661        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3662        // are legal, e.g. after switching install channels) previously
3663        // returned "whichever the OS happened to list first", which could
3664        // differ between two `inspect`/`audit`/`convert` runs against the
3665        // exact same directory. Collect every `opencode*.db` candidate and
3666        // pick deterministically: the exact `opencode.db` name wins if
3667        // present (the default/most-common channel); otherwise the
3668        // lexicographically-smallest match, so repeated runs always agree.
3669        let mut candidates: Vec<PathBuf> = entries
3670            .flatten()
3671            .map(|entry| entry.path())
3672            .filter(|p| {
3673                p.file_name()
3674                    .and_then(|n| n.to_str())
3675                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3676            })
3677            .collect();
3678        candidates.sort();
3679        if let Some(exact) = candidates
3680            .iter()
3681            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3682        {
3683            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3684        }
3685        if let Some(first) = candidates.into_iter().next() {
3686            return Some((OpenCodeStorageSurface::Sqlite, first));
3687        }
3688    }
3689    let storage = data_root.join("storage");
3690    if storage.join("migration").is_file() {
3691        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3692    }
3693    let project_dir = data_root.join("project");
3694    if project_dir.is_dir() {
3695        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3696    }
3697    None
3698}
3699
3700/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3701/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3702///
3703/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3704/// `\r` survives as part of the returned line's own content; blank lines and
3705/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3706/// trimmed. This is what makes `Session.raw` — populated from this at every
3707/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3708/// just well-formed LF JSONL with no blank lines.
3709///
3710/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3711/// distinguish a source that ended with a trailing newline from one that
3712/// didn't (both split into the same line list), so `ends_with_newline`
3713/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3714/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3715/// source has zero lines, not one blank line.
3716fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3717    if text.is_empty() {
3718        return (Vec::new(), false);
3719    }
3720    let ends_with_newline = text.ends_with('\n');
3721    let body = if ends_with_newline {
3722        &text[..text.len() - 1]
3723    } else {
3724        text
3725    };
3726    (body.split('\n').collect(), ends_with_newline)
3727}
3728
3729/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3730/// source bytes from its verbatim lines plus the trailing-newline flag.
3731fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3732    let mut out = lines.join("\n");
3733    if ends_with_newline {
3734        out.push('\n');
3735    }
3736    out
3737}
3738
3739// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3740//
3741// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3742// SQLite — no system library dependency) and reconstructs the SAME envelope
3743// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3744// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3745// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3746// `session.ts` `fromRow` (session table: columnar fields recombined into the
3747// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3748// carried as the RAW column value, not upstream's own `fromRow`
3749// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3750// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3751// `message`/`part` rows are simpler: their `data` column is already the V1
3752// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3753// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3754// `id`/`sessionID`(/`messageID`).
3755
3756/// First 16 bytes of every SQLite database file — the format's own magic,
3757/// independent of file extension.
3758const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3759
3760/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3761/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3762/// the SQLite magic, OR its extension is `.db` — the latter so a
3763/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3764/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3765/// A non-existent path is NOT considered SQLite here — the missing-file
3766/// diagnostic in that case comes from the normal load path (`with_context`
3767/// at the CLI call sites), which already names the path clearly.
3768pub fn looks_like_sqlite(path: &Path) -> bool {
3769    if !path.is_file() {
3770        return false;
3771    }
3772    if path.extension().and_then(|e| e.to_str()) == Some("db") {
3773        return true;
3774    }
3775    use std::io::Read;
3776    let Ok(mut f) = std::fs::File::open(path) else {
3777        return false;
3778    };
3779    let mut buf = [0u8; 16];
3780    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3781}
3782
3783/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3784/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3785/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3786/// accept there. Binary SQLite input never reaches this function: callers
3787/// check [`looks_like_sqlite`] first and route to
3788/// [`Session::from_opencode_sqlite`] instead.
3789fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3790    let bytes = std::fs::read(path)?;
3791    String::from_utf8(bytes).map_err(|_| {
3792        crate::Error::Other(format!(
3793            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3794             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3795             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3796            path.display()
3797        ))
3798    })
3799}
3800
3801/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3802///
3803/// The first record carries durable session metadata (especially for Codex),
3804/// while the trailing window carries the messages the viewport will render.
3805/// Full lossless loaders intentionally continue to read every byte.
3806fn read_display_jsonl(
3807    path: &Path,
3808    message_limit: usize,
3809) -> Result<(Option<SessionSource>, String)> {
3810    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3811    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3812    const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3813
3814    let mut first = String::new();
3815    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3816    let source = detect_source(&first);
3817    if !matches!(
3818        source,
3819        Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
3820    ) {
3821        let text = read_utf8_or_diagnose(path)?;
3822        return Ok((detect_source(&text), text));
3823    }
3824
3825    let mut file = std::fs::File::open(path)?;
3826    let file_len = file.metadata()?.len();
3827    let requested = (message_limit.max(1) as u64)
3828        .saturating_mul(BYTES_PER_MESSAGE)
3829        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3830    if file_len <= requested {
3831        let text = read_utf8_or_diagnose(path)?;
3832        return Ok((source, text));
3833    }
3834
3835    let start = file_len - requested;
3836    file.seek(SeekFrom::Start(start))?;
3837    let mut bytes = Vec::with_capacity(requested as usize);
3838    file.read_to_end(&mut bytes)?;
3839    // The window normally starts in the middle of a JSON record. Discard that
3840    // partial prefix so every line passed to the existing parsers is valid.
3841    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3842        bytes.drain(..=newline);
3843    }
3844    let mut tail = String::from_utf8(bytes).map_err(|_| {
3845        crate::Error::Other(format!(
3846            "{} contains non-UTF-8 data in its display window",
3847            path.display()
3848        ))
3849    })?;
3850    if !tail
3851        .lines()
3852        .any(|line| native_display_human_line(line, source))
3853    {
3854        // A single tool-heavy turn can exceed the ordinary byte window. Search backward through a
3855        // separately bounded native slice for only its nearest human record, then prepend that one
3856        // line to the cheap tail. The skipped megabytes are never normalized or sent over RPC.
3857        let search_bytes = file_len.min(requested.saturating_mul(2).min(MAX_TAIL_BYTES));
3858        let search_start = file_len - search_bytes;
3859        file.seek(SeekFrom::Start(search_start))?;
3860        let mut search = Vec::with_capacity(search_bytes as usize);
3861        file.read_to_end(&mut search)?;
3862        if search_start > 0 {
3863            if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3864                search.drain(..=newline);
3865            }
3866        }
3867        if let Ok(search) = std::str::from_utf8(&search) {
3868            if let Some(anchor) = search
3869                .lines()
3870                .rev()
3871                .find(|line| native_display_human_line(line, source))
3872            {
3873                tail = format!("{anchor}\n{tail}");
3874            }
3875        }
3876    }
3877    let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
3878        format!("{first}{tail}")
3879    } else {
3880        tail
3881    };
3882    Ok((source, text))
3883}
3884
3885fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3886    if !line
3887        .as_bytes()
3888        .windows(6)
3889        .any(|window| window == b"\"user\"")
3890    {
3891        return false;
3892    }
3893    let Ok(value) = serde_json::from_str::<Value>(line) else {
3894        return false;
3895    };
3896    match source {
3897        Some(SessionSource::Codex) => {
3898            value.get("type").and_then(Value::as_str) == Some("response_item")
3899                && value
3900                    .get("payload")
3901                    .and_then(|payload| payload.get("type"))
3902                    .and_then(Value::as_str)
3903                    == Some("message")
3904                && value
3905                    .get("payload")
3906                    .and_then(|payload| payload.get("role"))
3907                    .and_then(Value::as_str)
3908                    == Some("user")
3909        }
3910        Some(SessionSource::ClaudeCode) => {
3911            value.get("type").and_then(Value::as_str) == Some("user")
3912                && value
3913                    .get("message")
3914                    .and_then(|message| message.get("content"))
3915                    .is_some_and(|content| match content {
3916                        Value::String(text) => !text.trim().is_empty(),
3917                        Value::Array(parts) => parts.iter().any(|part| {
3918                            part.get("type").and_then(Value::as_str) == Some("text")
3919                                && part
3920                                    .get("text")
3921                                    .and_then(Value::as_str)
3922                                    .is_some_and(|text| !text.trim().is_empty())
3923                        }),
3924                        _ => false,
3925                    })
3926        }
3927        Some(SessionSource::Gemini) => {
3928            value.get("type").and_then(Value::as_str) == Some("user")
3929                && value.get("content").is_some_and(|content| match content {
3930                    Value::String(text) => !text.trim().is_empty(),
3931                    Value::Array(parts) => parts.iter().any(|part| {
3932                        part.get("text")
3933                            .and_then(Value::as_str)
3934                            .is_some_and(|text| !text.trim().is_empty())
3935                    }),
3936                    _ => false,
3937                })
3938        }
3939        _ => false,
3940    }
3941}
3942
3943fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3944    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3945}
3946
3947/// Open `db_path` read-only and confirm it carries the expected V1 schema
3948/// (a `session` table) — the shared entry point for every SQLite read below,
3949/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
3950/// path, not-a-database, and wrong/unsupported schema are each named
3951/// distinctly rather than surfacing later as "zero sessions" or a generic
3952/// parse failure.
3953fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
3954    if !db_path.is_file() {
3955        return Err(crate::Error::Other(format!(
3956            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
3957             (see `docs/interop/opencode-pi-spec.md` §1.2)",
3958            db_path.display()
3959        )));
3960    }
3961    let conn = Connection::open_with_flags(
3962        db_path,
3963        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3964    )
3965    .map_err(|e| {
3966        crate::Error::Other(format!(
3967            "{} does not look like a valid OpenCode SQLite database: {e}",
3968            db_path.display()
3969        ))
3970    })?;
3971    let has_session_table: i64 = conn
3972        .query_row(
3973            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
3974            [],
3975            |r| r.get(0),
3976        )
3977        .map_err(|e| {
3978            crate::Error::Other(format!(
3979                "failed to read the OpenCode SQLite schema at {}: {e}",
3980                db_path.display()
3981            ))
3982        })?;
3983    if has_session_table == 0 {
3984        return Err(crate::Error::Other(format!(
3985            "{} is a SQLite database but has no `session` table — not a recognized \
3986             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
3987            db_path.display()
3988        )));
3989    }
3990    Ok(conn)
3991}
3992
3993/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
3994/// …). D7: an unparseable non-empty column previously degraded to
3995/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
3996/// absent/NULL column, so a corrupt `data`/`metadata` value silently
3997/// vanished (e.g. a message whose `data` fails to parse loses its entire
3998/// canonical content with no trace). A `tracing::warn!` now surfaces the
3999/// column name and context (session/record id) whenever this happens, so
4000/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
4001/// (still the least-wrong placeholder for a broken column; changing it to a
4002/// sentinel would risk misleading every legitimate `.is_null()` check
4003/// elsewhere) but the frontend/log now knows it happened.
4004fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
4005    match s.as_deref() {
4006        None => Value::Null,
4007        Some(t) => match serde_json::from_str::<Value>(t) {
4008            Ok(v) => v,
4009            Err(e) => {
4010                tracing::warn!(
4011                    column = col,
4012                    context,
4013                    error = %e,
4014                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
4015                );
4016                Value::Null
4017            }
4018        },
4019    }
4020}
4021
4022/// Columns the `session` table has in a GIVEN store, read once per session
4023/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4024/// `opencode` generation may lack columns the newest schema added, e.g.
4025/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4026/// "Invalid column name" on an absent column, so callers must check
4027/// membership before reading a not-guaranteed column instead of reading it
4028/// unconditionally).
4029fn opencode_session_columns(
4030    conn: &Connection,
4031) -> rusqlite::Result<std::collections::HashSet<String>> {
4032    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4033    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4034    names.collect()
4035}
4036
4037/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4038/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4039/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4040/// `revert` carries the raw column value verbatim rather than upstream's
4041/// field-selecting reconstruction (spec S9c: that reconstruction silently
4042/// drops the V2 `Revert.State` schema's extra `files` field).
4043///
4044/// D3: not every column this loader would like to read is guaranteed to
4045/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4046/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4047/// `agent`/`model` entirely. Those are read defensively (guarded by
4048/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4049/// generation this loader has ever targeted are still read unconditionally.
4050fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4051    let cols = opencode_session_columns(conn)
4052        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4053    let has = |name: &str| cols.contains(name);
4054
4055    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4056        let id: String = r.get("id")?;
4057        let project_id: String = r.get("project_id")?;
4058        let workspace_id: Option<String> = if has("workspace_id") {
4059            r.get("workspace_id")?
4060        } else {
4061            None
4062        };
4063        let parent_id: Option<String> = r.get("parent_id")?;
4064        let slug: String = r.get("slug")?;
4065        let directory: String = r.get("directory")?;
4066        let path: Option<String> = if has("path") { r.get("path")? } else { None };
4067        let title: String = r.get("title")?;
4068        let version: String = r.get("version")?;
4069        let share_url: Option<String> = r.get("share_url")?;
4070        let summary_additions: Option<i64> = r.get("summary_additions")?;
4071        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4072        let summary_files: Option<i64> = r.get("summary_files")?;
4073        let summary_diffs: Option<String> = r.get("summary_diffs")?;
4074        let metadata: Option<String> = if has("metadata") {
4075            r.get("metadata")?
4076        } else {
4077            None
4078        };
4079        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4080        let tokens_input: i64 = if has("tokens_input") {
4081            r.get("tokens_input")?
4082        } else {
4083            0
4084        };
4085        let tokens_output: i64 = if has("tokens_output") {
4086            r.get("tokens_output")?
4087        } else {
4088            0
4089        };
4090        let tokens_reasoning: i64 = if has("tokens_reasoning") {
4091            r.get("tokens_reasoning")?
4092        } else {
4093            0
4094        };
4095        let tokens_cache_read: i64 = if has("tokens_cache_read") {
4096            r.get("tokens_cache_read")?
4097        } else {
4098            0
4099        };
4100        let tokens_cache_write: i64 = if has("tokens_cache_write") {
4101            r.get("tokens_cache_write")?
4102        } else {
4103            0
4104        };
4105        let revert: Option<String> = r.get("revert")?;
4106        let permission: Option<String> = if has("permission") {
4107            r.get("permission")?
4108        } else {
4109            None
4110        };
4111        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4112        let model: Option<String> = if has("model") { r.get("model")? } else { None };
4113        let time_created: i64 = r.get("time_created")?;
4114        let time_updated: i64 = r.get("time_updated")?;
4115        let time_compacting: Option<i64> = if has("time_compacting") {
4116            r.get("time_compacting")?
4117        } else {
4118            None
4119        };
4120        let time_archived: Option<i64> = if has("time_archived") {
4121            r.get("time_archived")?
4122        } else {
4123            None
4124        };
4125
4126        let summary =
4127            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4128                .then(|| {
4129                    serde_json::json!({
4130                        "additions": summary_additions.unwrap_or(0),
4131                        "deletions": summary_deletions.unwrap_or(0),
4132                        "files": summary_files.unwrap_or(0),
4133                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4134                    })
4135                });
4136        let share = share_url.map(|u| serde_json::json!({"url": u}));
4137
4138        Ok(serde_json::json!({
4139            "id": id,
4140            "slug": slug,
4141            "projectID": project_id,
4142            "workspaceID": workspace_id,
4143            "directory": directory,
4144            "path": path,
4145            "parentID": parent_id,
4146            "summary": summary,
4147            "cost": cost,
4148            "tokens": {
4149                "input": tokens_input,
4150                "output": tokens_output,
4151                "reasoning": tokens_reasoning,
4152                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4153            },
4154            "share": share,
4155            "title": title,
4156            "agent": agent,
4157            "model": opencode_json_col(model, "model", session_id),
4158            "version": version,
4159            "metadata": opencode_json_col(metadata, "metadata", session_id),
4160            "time": {
4161                "created": time_created,
4162                "updated": time_updated,
4163                "compacting": time_compacting,
4164                "archived": time_archived,
4165            },
4166            "permission": opencode_json_col(permission, "permission", session_id),
4167            // S9c: raw column value, not a field-selecting reconstruction —
4168            // see this function's doc comment.
4169            "revert": opencode_json_col(revert, "revert", session_id),
4170        }))
4171    })
4172    .map_err(|e| match e {
4173        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4174            "OpenCode session `{session_id}` not found in this SQLite store"
4175        )),
4176        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4177    })
4178}
4179
4180/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4181/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4182/// re-inject them, matching what a JSON-tree file (or the export document)
4183/// carries at this same key. Also re-injects the row's own `time_created`/
4184/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4185/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4186/// in the envelope so `raw` is value-complete and re-writable without
4187/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4188/// which is a different, in-schema field with different semantics).
4189fn opencode_row_message_value(
4190    id: &str,
4191    session_id: &str,
4192    data_json: &str,
4193    time_created: i64,
4194    time_updated: i64,
4195) -> Value {
4196    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4197    if let Value::Object(map) = &mut v {
4198        map.insert("id".to_string(), Value::String(id.to_string()));
4199        map.insert(
4200            "sessionID".to_string(),
4201            Value::String(session_id.to_string()),
4202        );
4203        map.insert("time_created".to_string(), Value::from(time_created));
4204        map.insert("time_updated".to_string(), Value::from(time_updated));
4205    }
4206    v
4207}
4208
4209fn opencode_row_part_value(
4210    id: &str,
4211    session_id: &str,
4212    message_id: &str,
4213    data_json: &str,
4214    time_created: i64,
4215    time_updated: i64,
4216) -> Value {
4217    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4218    if let Value::Object(map) = &mut v {
4219        map.insert("id".to_string(), Value::String(id.to_string()));
4220        map.insert(
4221            "sessionID".to_string(),
4222            Value::String(session_id.to_string()),
4223        );
4224        map.insert(
4225            "messageID".to_string(),
4226            Value::String(message_id.to_string()),
4227        );
4228        map.insert("time_created".to_string(), Value::from(time_created));
4229        map.insert("time_updated".to_string(), Value::from(time_updated));
4230    }
4231    v
4232}
4233
4234/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4235/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4236/// info first, then each message (by `time_created, id`) immediately
4237/// followed by its own parts (by `id`) — parts MUST directly follow their
4238/// owning message line, since `Session::from_opencode_str`'s envelope parser
4239/// attaches a `part` line to whichever message id is already in its index
4240/// and silently leaves an out-of-order part `raw`-only otherwise — then
4241/// `todo` side-records, then a `session_diff` side-record if the JSON
4242/// sidecar file for this session exists (order-independent).
4243///
4244/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4245/// it "is still JSON-written even on SQLite installs" — verified against
4246/// `packages/opencode/src/session/revert.ts:76` /
4247/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4248/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4249/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4250/// separate from the `session.revert` DB column this loader already
4251/// captures. Without this, revert diffs vanish from `raw` and audit
4252/// under-counts `session_diff` records for real reverted sessions.
4253fn opencode_sqlite_session_envelope_lines(
4254    conn: &Connection,
4255    db_path: &Path,
4256    session_id: &str,
4257) -> Result<Vec<String>> {
4258    let mut lines = Vec::new();
4259
4260    let session_info = opencode_row_session_info(conn, session_id)?;
4261    let project_id = session_info
4262        .get("projectID")
4263        .and_then(Value::as_str)
4264        .unwrap_or("global")
4265        .to_string();
4266    lines.push(
4267        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4268            .to_string(),
4269    );
4270
4271    let mut msg_stmt = conn
4272        .prepare(
4273            "SELECT id, data, time_created, time_updated FROM message \
4274             WHERE session_id = ?1 ORDER BY time_created, id",
4275        )
4276        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4277    let msg_rows = msg_stmt
4278        .query_map([session_id], |r| {
4279            let id: String = r.get("id")?;
4280            let data: String = r.get("data")?;
4281            let time_created: i64 = r.get("time_created")?;
4282            let time_updated: i64 = r.get("time_updated")?;
4283            Ok((id, data, time_created, time_updated))
4284        })
4285        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4286
4287    let mut part_stmt = conn
4288        .prepare(
4289            "SELECT id, data, time_created, time_updated FROM part \
4290             WHERE message_id = ?1 ORDER BY id",
4291        )
4292        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4293
4294    for row in msg_rows {
4295        let (msg_id, data, msg_time_created, msg_time_updated) =
4296            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4297        let msg_value = opencode_row_message_value(
4298            &msg_id,
4299            session_id,
4300            &data,
4301            msg_time_created,
4302            msg_time_updated,
4303        );
4304        lines.push(
4305            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4306                .to_string(),
4307        );
4308
4309        let part_rows = part_stmt
4310            .query_map([&msg_id], |r| {
4311                let id: String = r.get("id")?;
4312                let data: String = r.get("data")?;
4313                let time_created: i64 = r.get("time_created")?;
4314                let time_updated: i64 = r.get("time_updated")?;
4315                Ok((id, data, time_created, time_updated))
4316            })
4317            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4318        for prow in part_rows {
4319            let (part_id, pdata, part_time_created, part_time_updated) =
4320                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4321            let part_value = opencode_row_part_value(
4322                &part_id,
4323                session_id,
4324                &msg_id,
4325                &pdata,
4326                part_time_created,
4327                part_time_updated,
4328            );
4329            lines.push(
4330                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4331                    .to_string(),
4332            );
4333        }
4334    }
4335
4336    let mut todo_stmt = conn
4337        .prepare(
4338            "SELECT content, status, priority, position, time_created, time_updated \
4339             FROM todo WHERE session_id = ?1 ORDER BY position",
4340        )
4341        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4342    let todo_rows = todo_stmt
4343        .query_map([session_id], |r| {
4344            let content: String = r.get("content")?;
4345            let status: String = r.get("status")?;
4346            let priority: String = r.get("priority")?;
4347            let position: i64 = r.get("position")?;
4348            let time_created: i64 = r.get("time_created")?;
4349            let time_updated: i64 = r.get("time_updated")?;
4350            Ok(serde_json::json!({
4351                "sessionID": session_id,
4352                "content": content,
4353                "status": status,
4354                "priority": priority,
4355                "position": position,
4356                "time": {"created": time_created, "updated": time_updated},
4357            }))
4358        })
4359        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4360    for trow in todo_rows {
4361        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4362        let position = tv.get("position").cloned().unwrap_or(Value::Null);
4363        lines.push(
4364            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4365        );
4366    }
4367
4368    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4369        lines.push(
4370            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4371                .to_string(),
4372        );
4373    }
4374
4375    Ok(lines)
4376}
4377
4378/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4379/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4380/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4381/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4382/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4383/// case (most sessions never revert) and is not an error; an existing-but-
4384/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4385/// vanishing.
4386fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4387    let dir = db_path.parent()?;
4388    let sidecar = dir
4389        .join("storage")
4390        .join("session_diff")
4391        .join(format!("{session_id}.json"));
4392    let text = std::fs::read_to_string(&sidecar).ok()?;
4393    match serde_json::from_str::<Value>(&text) {
4394        Ok(v) => Some(v),
4395        Err(e) => {
4396            tracing::warn!(
4397                path = %sidecar.display(),
4398                error = %e,
4399                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4400            );
4401            None
4402        }
4403    }
4404}
4405
4406/// Pick the "primary" session for a bare `.db` path with no explicit session
4407/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4408/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4409/// descending) — a subagent/task child session is never picked over an
4410/// available root session, mirroring `most_recent_session`'s "latest wins"
4411/// convention used elsewhere in this crate for supercode's own store.
4412fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4413    conn.query_row(
4414        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4415        [],
4416        |r| r.get::<_, String>(0),
4417    )
4418    .map_err(|e| match e {
4419        rusqlite::Error::QueryReturnedNoRows => {
4420            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4421        }
4422        e => opencode_sql_err(e, "selecting the primary session"),
4423    })
4424}
4425
4426fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4427    let mut stmt = conn
4428        .prepare("SELECT id FROM session ORDER BY time_created, id")
4429        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4430    let rows = stmt
4431        .query_map([], |r| r.get::<_, String>(0))
4432        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4433    let mut ids = Vec::new();
4434    for row in rows {
4435        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4436        if limit.is_some_and(|n| ids.len() >= n) {
4437            break;
4438        }
4439    }
4440    Ok(ids)
4441}
4442
4443/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4444/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4445/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4446/// silently picks just the primary one. Previously nothing surfaced this:
4447/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4448/// and no way to name a different one.
4449pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4450    let conn = opencode_sqlite_open(db_path)?;
4451    opencode_sqlite_all_session_ids(&conn, None)
4452}
4453
4454/// D6: the same "most-recently-updated top-level session" selection
4455/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4456/// no explicit session id is given — exposed so a CLI-level warning can name
4457/// which one was chosen.
4458pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4459    let conn = opencode_sqlite_open(db_path)?;
4460    opencode_sqlite_primary_session_id(&conn)
4461}
4462
4463/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4464/// `inspect`'s "reports the audited real store's sessions, messages, and
4465/// parts" summary (PARITY-3 AC01).
4466#[derive(Debug, Clone, Copy, Default)]
4467#[non_exhaustive]
4468pub struct OpenCodeSqliteStoreStats {
4469    /// Row count of the `session` table.
4470    pub sessions: u64,
4471    /// Row count of the `message` table.
4472    pub messages: u64,
4473    /// Row count of the `part` table.
4474    pub parts: u64,
4475    /// Row count of the `todo` table.
4476    pub todos: u64,
4477}
4478
4479/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4480/// without loading any of them (PARITY-3 AC01).
4481pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4482    let conn = opencode_sqlite_open(db_path)?;
4483    let count = |table: &str| -> Result<u64> {
4484        let sql = format!("SELECT count(*) FROM {table}");
4485        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4486            .map(|n| n.max(0) as u64)
4487            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4488    };
4489    Ok(OpenCodeSqliteStoreStats {
4490        sessions: count("session")?,
4491        messages: count("message")?,
4492        parts: count("part")?,
4493        todos: count("todo")?,
4494    })
4495}
4496
4497/// Combined envelope text spanning every session in `db_path` (or up to
4498/// `limit_sessions`) — for corpus-style scanning
4499/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4500/// Safe to concatenate multiple sessions' records into one text even though
4501/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4502/// (single-session semantics) — the audit line-classifier
4503/// (`audit_opencode_line`) scores each line independently and doesn't care
4504/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4505/// one session as a real [`Session`].
4506pub fn opencode_sqlite_corpus_envelope_text(
4507    db_path: &Path,
4508    limit_sessions: Option<usize>,
4509) -> Result<String> {
4510    let conn = opencode_sqlite_open(db_path)?;
4511    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4512    let mut out = String::new();
4513    for id in ids {
4514        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4515            out.push_str(&line);
4516            out.push('\n');
4517        }
4518    }
4519    Ok(out)
4520}
4521
4522/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4523/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4524/// everywhere a loader walks lines looking for JSON *records*, where a blank
4525/// line is simply not a record and must not become a spurious parse
4526/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4527/// (IX-1) — see [`split_lines_verbatim`] for that.
4528fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4529    text.lines().map(str::trim).filter(|l| !l.is_empty())
4530}
4531
4532// ---- Claude Code ----------------------------------------------------------
4533
4534/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4535/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4536fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4537    let dir = main_path.parent()?;
4538    let stem = main_path.file_stem()?.to_str()?;
4539    let candidate = dir.join(stem).join("subagents");
4540    candidate.is_dir().then_some(candidate)
4541}
4542
4543/// The first `agentId` recorded in a subagent transcript.
4544fn first_agent_id(jsonl: &str) -> Option<String> {
4545    for line in non_empty_lines(jsonl) {
4546        if let Ok(v) = serde_json::from_str::<Value>(line) {
4547            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4548                return Some(id.to_string());
4549            }
4550        }
4551    }
4552    None
4553}
4554
4555/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4556/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4557/// serialized content mentions the agent id. Best effort: an id with no
4558/// qualifying match is simply absent from the returned map.
4559///
4560/// Single pass over `main_text` — each line is parsed at most once,
4561/// regardless of how many agent ids are being sought — with each id's result
4562/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4563/// return: the first line (in file order) whose raw text contains the id and
4564/// which — the first qualifying `tool_result` block in that line, in block
4565/// order — has a string `tool_use_id` and a serialized form that also
4566/// contains the id. A `tool_result` block matching on raw-line/serialized
4567/// containment but lacking a `tool_use_id` yields nothing for that id and
4568/// does not shadow a later match.
4569fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4570    let mut index: HashMap<String, String> = HashMap::new();
4571    if agent_ids.is_empty() {
4572        return index;
4573    }
4574
4575    for line in non_empty_lines(main_text) {
4576        if index.len() == agent_ids.len() {
4577            break;
4578        }
4579        // Cheap prefilter: every match this function can ever return comes
4580        // from a block whose raw line carries the literal JSON string value
4581        // `tool_result` (no JSON-escape variants of that ASCII literal).
4582        if !line.contains("tool_result") {
4583            continue;
4584        }
4585        let still_unmapped: Vec<&String> = agent_ids
4586            .iter()
4587            .filter(|id| !index.contains_key(id.as_str()))
4588            .collect();
4589        if still_unmapped.is_empty() {
4590            break;
4591        }
4592        let Ok(v) = serde_json::from_str::<Value>(line) else {
4593            continue;
4594        };
4595        let content = v.get("message").and_then(|m| m.get("content"));
4596        let Some(Value::Array(blocks)) = content else {
4597            continue;
4598        };
4599        for b in blocks {
4600            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4601                continue;
4602            }
4603            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4604                continue;
4605            };
4606            let block_str = b.to_string();
4607            for id in &still_unmapped {
4608                if index.contains_key(id.as_str()) {
4609                    continue;
4610                }
4611                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4612                    index.insert((*id).clone(), tool_use_id.to_string());
4613                }
4614            }
4615        }
4616    }
4617
4618    index
4619}
4620
4621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4622enum ClaudeReplayKind {
4623    User,
4624    Assistant,
4625    Attachment,
4626    System,
4627}
4628
4629impl ClaudeReplayKind {
4630    fn is_conversation(self) -> bool {
4631        matches!(self, Self::User | Self::Assistant)
4632    }
4633}
4634
4635#[derive(Debug, Clone)]
4636struct ClaudeReplayNode {
4637    line_index: usize,
4638    uuid: String,
4639    parent_uuid: Option<String>,
4640    kind: ClaudeReplayKind,
4641    is_sidechain: bool,
4642    assistant_message_id: Option<String>,
4643    is_tool_result: bool,
4644    compact: Option<ClaudeCompactBoundary>,
4645}
4646
4647#[derive(Debug, Clone)]
4648struct ClaudeCompactBoundary {
4649    anchor_uuid: Option<String>,
4650    preserved_uuids: Vec<String>,
4651    preserved_segment: Option<(String, String)>,
4652}
4653
4654/// One projection of a Claude transcript graph: the source lines to replay,
4655/// plus whatever the projection had to give up to produce them (always empty
4656/// below [`Fidelity::Semantic`], which is the only level that degrades
4657/// instead of failing).
4658#[derive(Debug, Default)]
4659struct ClaudeReplaySelection {
4660    lines: Vec<usize>,
4661    residue: Vec<String>,
4662}
4663
4664#[derive(Debug, Default)]
4665struct ClaudeReplayIndex {
4666    nodes: Vec<ClaudeReplayNode>,
4667    by_uuid: HashMap<String, usize>,
4668    segment_anchors: HashSet<String>,
4669    last_prompt: Option<(String, bool)>,
4670    linear_lines: Vec<usize>,
4671}
4672
4673impl ClaudeReplayIndex {
4674    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4675        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4676            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4677                self.last_prompt = Some((
4678                    leaf.to_string(),
4679                    v.get("explicit").and_then(Value::as_bool) == Some(true),
4680                ));
4681            }
4682            return Ok(());
4683        }
4684
4685        // A fork-context-ref is a real Claude graph anchor, but not a replay
4686        // message. Its child is the first conversational record in the
4687        // exported fork, so reaching this UUID terminates the locally
4688        // replayable segment rather than indicating a broken parent edge.
4689        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4690            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4691                self.segment_anchors.insert(uuid.to_string());
4692            }
4693            return Ok(());
4694        }
4695
4696        let kind = match v.get("type").and_then(Value::as_str) {
4697            Some("user") => ClaudeReplayKind::User,
4698            Some("assistant") => ClaudeReplayKind::Assistant,
4699            Some("attachment") => ClaudeReplayKind::Attachment,
4700            Some("system") => ClaudeReplayKind::System,
4701            _ => return Ok(()),
4702        };
4703        self.linear_lines.push(line_index);
4704        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4705            return Ok(());
4706        };
4707        if self.by_uuid.contains_key(uuid) {
4708            return Err(claude_replay_error(format!(
4709                "duplicate uuid `{uuid}` in Claude transcript"
4710            )));
4711        }
4712
4713        let compact = (kind == ClaudeReplayKind::System
4714            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4715        .then(|| ClaudeCompactBoundary::from_value(v));
4716        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4717            .then(|| claude_assistant_message_id(v).map(str::to_string))
4718            .flatten();
4719        let is_tool_result = kind == ClaudeReplayKind::User
4720            && v.get("message")
4721                .and_then(|m| m.get("content"))
4722                .and_then(Value::as_array)
4723                .is_some_and(|blocks| {
4724                    blocks
4725                        .iter()
4726                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4727                });
4728        let node = ClaudeReplayNode {
4729            line_index,
4730            uuid: uuid.to_string(),
4731            parent_uuid: v
4732                .get("parentUuid")
4733                .and_then(Value::as_str)
4734                .map(str::to_string),
4735            kind,
4736            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4737            assistant_message_id,
4738            is_tool_result,
4739            compact,
4740        };
4741        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4742        self.nodes.push(node);
4743        Ok(())
4744    }
4745
4746    /// Project the transcript at `fidelity`.
4747    ///
4748    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4749    /// continuation, transfer and export path depends on: reconstruct
4750    /// Claude's own single active post-compaction branch, or fail naming what
4751    /// could not be reconstructed.
4752    ///
4753    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4754    /// that has been compacted, summarized, or resumed across files routinely
4755    /// contains a live record whose `parentUuid` names a record that is no
4756    /// longer on disk. Strict projection rightly refuses — a continuation
4757    /// built on a guessed graph is silent loss — but a VIEW does not need a
4758    /// continuation, so this mode anchors each dangling edge as a segment
4759    /// root, projects every severed segment exactly as the active branch is
4760    /// projected, splices them back together in transcript order, and names
4761    /// every degradation in the returned residue instead of erroring.
4762    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4763        let lenient = fidelity.tolerates_residue();
4764        let mut residue = Vec::new();
4765        if self.nodes.is_empty() {
4766            // Older exports and many hand-authored compatibility fixtures do
4767            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4768            // branch information to project in that shape, so preserve the
4769            // historical linear normalization behavior. Native graph-bearing
4770            // transcripts always take the projection below.
4771            return Ok(ClaudeReplaySelection {
4772                lines: self.linear_lines,
4773                residue,
4774            });
4775        }
4776        if lenient {
4777            self.anchor_dangling_parents(&mut residue);
4778        }
4779        // Last resort for a VIEW: a transcript whose graph is unprojectable
4780        // for some OTHER reason (a cycle, an unresolvable compact boundary)
4781        // still renders as the file's own record order. A read-only mirror
4782        // that cannot open a session at all is the defect this mode exists
4783        // to remove, so `Semantic` never returns an error.
4784        let fallback = lenient.then(|| self.linear_lines.clone());
4785        match self.project(lenient, &mut residue) {
4786            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4787            Err(error) => match fallback {
4788                Some(lines) => {
4789                    residue.push(format!(
4790                        "the Claude record graph could not be projected ({error}); \
4791                         every record was stitched in transcript order instead"
4792                    ));
4793                    Ok(ClaudeReplaySelection { lines, residue })
4794                }
4795                None => Err(error),
4796            },
4797        }
4798    }
4799
4800    /// Turn every edge that points outside the transcript into a segment
4801    /// root, naming the dangling uuids as residue.
4802    ///
4803    /// A `fork-context-ref` anchor is already a declared segment boundary,
4804    /// not a break, so it is left alone.
4805    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4806        let mut dangling = Vec::new();
4807        for idx in 0..self.nodes.len() {
4808            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4809                continue;
4810            };
4811            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4812                continue;
4813            }
4814            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4815            self.nodes[idx].parent_uuid = None;
4816        }
4817        if dangling.is_empty() {
4818            return;
4819        }
4820        const NAMED: usize = 8;
4821        let total = dangling.len();
4822        let overflow = total.saturating_sub(NAMED);
4823        dangling.truncate(NAMED);
4824        let mut listed = dangling.join(", ");
4825        if overflow > 0 {
4826            listed.push_str(&format!(", and {overflow} more"));
4827        }
4828        residue.push(format!(
4829            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4830             anchored as segment roots: {listed}"
4831        ));
4832    }
4833
4834    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4835        let mut retained = vec![true; self.nodes.len()];
4836        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4837            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4838                self.nodes
4839                    .iter()
4840                    .map(|node| node.parent_uuid.clone())
4841                    .collect()
4842            });
4843            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4844                let Some(parents) = parents else {
4845                    return Err(error);
4846                };
4847                // The boundary rewrites parents as it goes, so restore the
4848                // graph it half-edited before continuing without it.
4849                for (node, parent) in self.nodes.iter_mut().zip(parents) {
4850                    node.parent_uuid = parent;
4851                }
4852                retained.iter_mut().for_each(|keep| *keep = true);
4853                residue.push(format!(
4854                    "the latest Claude compact boundary could not be projected ({error}); \
4855                     no pre-compaction record was pruned from this view"
4856                ));
4857            }
4858        }
4859        let sidechain_only = self
4860            .nodes
4861            .iter()
4862            .enumerate()
4863            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4864            .all(|(_, node)| node.is_sidechain);
4865
4866        let explicit_leaf = self
4867            .last_prompt
4868            .as_ref()
4869            .filter(|(_, explicit)| *explicit)
4870            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4871            .filter(|idx| retained[*idx]);
4872        let newest_non_sidechain = self
4873            .nodes
4874            .iter()
4875            .enumerate()
4876            .rev()
4877            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4878            .map(|(idx, _)| idx);
4879        // Dedicated Claude subagent transcripts are sidechains by design:
4880        // every record, including their root user prompt, has
4881        // `isSidechain:true`. When there is no main-chain candidate, resume
4882        // the newest retained sidechain leaf instead of rejecting the child.
4883        let newest_sidechain = self
4884            .nodes
4885            .iter()
4886            .enumerate()
4887            .rev()
4888            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4889            .map(|(idx, _)| idx);
4890        let mut active = explicit_leaf
4891            .or(newest_non_sidechain)
4892            .or(newest_sidechain)
4893            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4894
4895        // Metadata descendants such as turn_duration are leaves in the raw
4896        // graph. Claude resumes from their nearest user/assistant ancestor,
4897        // then appends those descendants to the reconstructed chain.
4898        let mut seeking = HashSet::new();
4899        while !self.nodes[active].kind.is_conversation() {
4900            if !seeking.insert(active) {
4901                return Err(claude_replay_error(
4902                    "cycle while resolving active Claude leaf",
4903                ));
4904            }
4905            active = self.parent_index(active, &retained)?;
4906        }
4907
4908        let mut segments =
4909            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4910        if lenient {
4911            for leaf in self.severed_segment_leaves(active, &retained) {
4912                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4913            }
4914            if segments.len() > 1 {
4915                residue.push(format!(
4916                    "{} conversation segments were stitched in transcript order because the \
4917                     Claude record graph is severed",
4918                    segments.len()
4919                ));
4920            }
4921        }
4922        // Each segment keeps its own reconstructed order; the segments
4923        // themselves are spliced by where they start in the file.
4924        segments.retain(|segment| !segment.is_empty());
4925        segments.sort_by_key(|segment| {
4926            segment
4927                .iter()
4928                .map(|idx| self.nodes[*idx].line_index)
4929                .min()
4930                .unwrap_or(usize::MAX)
4931        });
4932        let mut ordered = Vec::new();
4933        let mut placed = HashSet::new();
4934        for idx in segments.into_iter().flatten() {
4935            if placed.insert(idx) {
4936                ordered.push(idx);
4937            }
4938        }
4939
4940        self.recover_parallel_assistant_chunks(ordered, &retained)
4941            .map(|indices| {
4942                indices
4943                    .into_iter()
4944                    .map(|idx| self.nodes[idx].line_index)
4945                    .collect()
4946            })
4947    }
4948
4949    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
4950    /// non-conversation descendants rooted at it.
4951    fn project_segment(
4952        &self,
4953        leaf: usize,
4954        retained: &[bool],
4955        sidechain_only: bool,
4956        lenient: bool,
4957    ) -> Result<Vec<usize>> {
4958        let mut reversed = Vec::new();
4959        let mut seen = HashSet::new();
4960        let mut cursor = Some(leaf);
4961        while let Some(idx) = cursor {
4962            if !seen.insert(idx) {
4963                return Err(claude_replay_error(format!(
4964                    "cycle in active Claude parentUuid chain at `{}`",
4965                    self.nodes[idx].uuid
4966                )));
4967            }
4968            reversed.push(idx);
4969            cursor = match self.nodes[idx].parent_uuid.as_deref() {
4970                Some(parent) => match self.by_uuid.get(parent).copied() {
4971                    Some(parent) => Some(parent),
4972                    None if self.segment_anchors.contains(parent) => None,
4973                    // Claude can resume a background child in-place while
4974                    // retaining only the new segment in that child's JSONL.
4975                    // Its first record then points to a UUID not present in
4976                    // the sidechain file. That external edge is a segment
4977                    // boundary, not corruption; the complete source remains
4978                    // available byte-for-byte in `raw`.
4979                    None if sidechain_only => None,
4980                    None => {
4981                        return Err(claude_replay_error(format!(
4982                            "active Claude record `{}` has missing parentUuid `{parent}`",
4983                            self.nodes[idx].uuid
4984                        )));
4985                    }
4986                },
4987                None => None,
4988            };
4989            if cursor.is_some_and(|parent| !retained[parent]) {
4990                if lenient {
4991                    // A compaction boundary is where this segment ends; the
4992                    // records it pruned stay pruned.
4993                    break;
4994                }
4995                return Err(claude_replay_error(format!(
4996                    "active Claude chain crosses an excluded compaction record from `{}`",
4997                    self.nodes[idx].uuid
4998                )));
4999            }
5000        }
5001        reversed.reverse();
5002
5003        // Include non-conversation descendants rooted at the segment's leaf
5004        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
5005        let mut descendants = Vec::new();
5006        let mut frontier = vec![leaf];
5007        let mut head = 0;
5008        while head < frontier.len() {
5009            let parent = frontier[head];
5010            head += 1;
5011            for (idx, node) in self.nodes.iter().enumerate() {
5012                if !retained[idx]
5013                    || node.kind.is_conversation()
5014                    || seen.contains(&idx)
5015                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
5016                {
5017                    continue;
5018                }
5019                seen.insert(idx);
5020                descendants.push(idx);
5021                frontier.push(idx);
5022            }
5023        }
5024        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5025        reversed.extend(descendants);
5026        Ok(reversed)
5027    }
5028
5029    /// The newest retained conversation record of every component the active
5030    /// leaf's own component cannot reach.
5031    ///
5032    /// Only a severed graph produces any: a healthy transcript is one
5033    /// component, so the abandoned branches a rewind left behind stay
5034    /// abandoned here exactly as they do under strict projection.
5035    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5036        let active_root = self.component_root(active, retained);
5037        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5038        for idx in 0..self.nodes.len() {
5039            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5040                continue;
5041            }
5042            let Some(root) = self.component_root(idx, retained) else {
5043                continue;
5044            };
5045            if Some(root) == active_root {
5046                continue;
5047            }
5048            let newest = newest_by_root.entry(root).or_insert(idx);
5049            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5050                *newest = idx;
5051            }
5052        }
5053        newest_by_root.into_values().collect()
5054    }
5055
5056    /// Walk `idx` up to the record that anchors its component, stopping at a
5057    /// root, an edge that leaves the transcript, or a pruned parent. `None`
5058    /// when the walk cycles.
5059    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5060        let mut cursor = idx;
5061        let mut seen = HashSet::new();
5062        loop {
5063            if !seen.insert(cursor) {
5064                return None;
5065            }
5066            let next = self.nodes[cursor]
5067                .parent_uuid
5068                .as_deref()
5069                .and_then(|parent| self.by_uuid.get(parent).copied())
5070                .filter(|parent| retained[*parent]);
5071            match next {
5072                Some(parent) => cursor = parent,
5073                None => return Some(cursor),
5074            }
5075        }
5076    }
5077
5078    fn apply_latest_compaction(
5079        &mut self,
5080        boundary_index: usize,
5081        retained: &mut [bool],
5082    ) -> Result<()> {
5083        let compact = self.nodes[boundary_index]
5084            .compact
5085            .clone()
5086            .expect("called with compact boundary");
5087        let mut preserved = compact.preserved_uuids;
5088        if preserved.is_empty() {
5089            if let Some((head, tail)) = compact.preserved_segment {
5090                preserved = self.walk_preserved_segment(&head, &tail)?;
5091            }
5092        }
5093
5094        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5095        for uuid in &preserved {
5096            if !self.by_uuid.contains_key(uuid) {
5097                return Err(claude_replay_error(format!(
5098                    "latest compact boundary references missing preserved uuid `{uuid}`"
5099                )));
5100            }
5101        }
5102
5103        let removed_uuids: HashSet<String> = self
5104            .nodes
5105            .iter()
5106            .enumerate()
5107            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5108            .map(|(_, node)| node.uuid.clone())
5109            .collect();
5110        for (idx, node) in self.nodes.iter().enumerate() {
5111            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5112                retained[idx] = false;
5113            }
5114        }
5115
5116        if preserved.is_empty() {
5117            return Ok(());
5118        }
5119        let anchor = compact.anchor_uuid.ok_or_else(|| {
5120            claude_replay_error("preserved compact boundary is missing anchorUuid")
5121        })?;
5122        if !self.by_uuid.contains_key(&anchor) {
5123            return Err(claude_replay_error(format!(
5124                "latest compact boundary references missing anchor uuid `{anchor}`"
5125            )));
5126        }
5127        let tail = preserved.last().cloned().expect("non-empty preserved list");
5128        let mut parent = anchor.clone();
5129        for uuid in &preserved {
5130            let idx = self.by_uuid[uuid];
5131            self.nodes[idx].parent_uuid = Some(parent);
5132            parent = uuid.clone();
5133        }
5134        let first = &preserved[0];
5135        for node in &mut self.nodes {
5136            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5137                node.parent_uuid = Some(tail.clone());
5138            }
5139        }
5140        for node in &mut self.nodes {
5141            if node.kind.is_conversation()
5142                && node
5143                    .parent_uuid
5144                    .as_ref()
5145                    .is_some_and(|parent| removed_uuids.contains(parent))
5146            {
5147                node.parent_uuid = Some(tail.clone());
5148            }
5149        }
5150        Ok(())
5151    }
5152
5153    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5154        let mut reversed = Vec::new();
5155        let mut seen = HashSet::new();
5156        let mut cursor = tail;
5157        loop {
5158            if !seen.insert(cursor.to_string()) {
5159                return Err(claude_replay_error("cycle in compact preservedSegment"));
5160            }
5161            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5162                claude_replay_error(format!(
5163                    "compact preservedSegment references missing uuid `{cursor}`"
5164                ))
5165            })?;
5166            reversed.push(cursor.to_string());
5167            if cursor == head {
5168                reversed.reverse();
5169                return Ok(reversed);
5170            }
5171            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5172                claude_replay_error(format!(
5173                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5174                ))
5175            })?;
5176        }
5177    }
5178
5179    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5180        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5181            claude_replay_error(format!(
5182                "Claude record `{}` has no conversational ancestor",
5183                self.nodes[idx].uuid
5184            ))
5185        })?;
5186        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5187            claude_replay_error(format!(
5188                "Claude record `{}` has missing parentUuid `{parent}`",
5189                self.nodes[idx].uuid
5190            ))
5191        })?;
5192        if !retained[parent_idx] {
5193            return Err(claude_replay_error(format!(
5194                "Claude record `{}` points into compacted-out history",
5195                self.nodes[idx].uuid
5196            )));
5197        }
5198        Ok(parent_idx)
5199    }
5200
5201    fn recover_parallel_assistant_chunks(
5202        &self,
5203        base: Vec<usize>,
5204        retained: &[bool],
5205    ) -> Result<Vec<usize>> {
5206        let selected: HashSet<usize> = base.iter().copied().collect();
5207        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5208        let mut skipped_positions = HashSet::new();
5209        let mut handled_ids = HashSet::new();
5210
5211        for (base_pos, idx) in base.iter().copied().enumerate() {
5212            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5213                continue;
5214            };
5215            if !handled_ids.insert(message_id.to_string()) {
5216                continue;
5217            }
5218            let base_positions: Vec<usize> = base
5219                .iter()
5220                .enumerate()
5221                .filter(|(_, candidate)| {
5222                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5223                })
5224                .map(|(pos, _)| pos)
5225                .collect();
5226            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5227            skipped_positions.extend(base_positions.iter().copied().skip(1));
5228
5229            // A streamed Anthropic response can be stored as sibling records
5230            // rather than a literal parent chain. Reassemble every chunk at
5231            // the first active occurrence and restore raw chunk order before
5232            // the normalizer coalesces their content blocks.
5233            let mut chunks: Vec<usize> = self
5234                .nodes
5235                .iter()
5236                .enumerate()
5237                .filter(|(candidate, node)| {
5238                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5239                })
5240                .map(|(candidate, _)| candidate)
5241                .collect();
5242            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5243
5244            let assistant_uuids: HashSet<&str> = self
5245                .nodes
5246                .iter()
5247                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5248                .map(|node| node.uuid.as_str())
5249                .collect();
5250            let mut results: Vec<usize> = self
5251                .nodes
5252                .iter()
5253                .enumerate()
5254                .filter(|(candidate, node)| {
5255                    retained[*candidate]
5256                        && !selected.contains(candidate)
5257                        && node.is_tool_result
5258                        && node
5259                            .parent_uuid
5260                            .as_deref()
5261                            .is_some_and(|parent| assistant_uuids.contains(parent))
5262                })
5263                .map(|(candidate, _)| candidate)
5264                .collect();
5265            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5266            chunks.extend(results);
5267            replacements.insert(anchor_pos, chunks);
5268        }
5269
5270        let mut out = Vec::with_capacity(selected.len());
5271        for (pos, idx) in base.into_iter().enumerate() {
5272            if let Some(replacement) = replacements.remove(&pos) {
5273                out.extend(replacement);
5274            } else if !skipped_positions.contains(&pos) {
5275                out.push(idx);
5276            }
5277        }
5278        Ok(out)
5279    }
5280}
5281
5282impl ClaudeCompactBoundary {
5283    fn from_value(v: &Value) -> Self {
5284        let metadata = v.get("compactMetadata");
5285        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5286        let anchor_uuid = preserved_messages
5287            .and_then(|p| p.get("anchorUuid"))
5288            .and_then(Value::as_str)
5289            .or_else(|| {
5290                metadata
5291                    .and_then(|m| m.get("preservedSegment"))
5292                    .and_then(|p| p.get("anchorUuid"))
5293                    .and_then(Value::as_str)
5294            })
5295            .map(str::to_string);
5296        let preserved_uuids = preserved_messages
5297            .and_then(|p| p.get("uuids"))
5298            .and_then(Value::as_array)
5299            .map(|uuids| {
5300                uuids
5301                    .iter()
5302                    .filter_map(Value::as_str)
5303                    .map(str::to_string)
5304                    .collect()
5305            })
5306            .unwrap_or_default();
5307        let preserved_segment =
5308            metadata
5309                .and_then(|m| m.get("preservedSegment"))
5310                .and_then(|segment| {
5311                    Some((
5312                        segment.get("headUuid")?.as_str()?.to_string(),
5313                        segment.get("tailUuid")?.as_str()?.to_string(),
5314                    ))
5315                });
5316        Self {
5317            anchor_uuid,
5318            preserved_uuids,
5319            preserved_segment,
5320        }
5321    }
5322}
5323
5324fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5325    crate::Error::Other(format!(
5326        "cannot reconstruct lossless Claude continuation: {}",
5327        message.into()
5328    ))
5329}
5330
5331fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5332    v.get("message")
5333        .and_then(|message| message.get("id"))
5334        .and_then(Value::as_str)
5335}
5336
5337fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5338    let Some(target_message) = target.get_mut("message") else {
5339        return;
5340    };
5341    let Some(chunk_message) = chunk.get("message") else {
5342        return;
5343    };
5344    let mut content = target_message
5345        .get("content")
5346        .and_then(Value::as_array)
5347        .cloned()
5348        .unwrap_or_default();
5349    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5350        content.extend(blocks.iter().cloned());
5351    }
5352    let mut merged_message = chunk_message.clone();
5353    merged_message["content"] = Value::Array(content);
5354    *target_message = merged_message;
5355}
5356
5357fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5358    let Some(v) = pending.take() else {
5359        return;
5360    };
5361    let reasoning_only = claude_assistant_message_id(&v).is_some()
5362        && v.get("message")
5363            .and_then(|message| message.get("content"))
5364            .and_then(Value::as_array)
5365            .is_some_and(|blocks| {
5366                !blocks.is_empty()
5367                    && blocks.iter().all(|block| {
5368                        matches!(
5369                            block.get("type").and_then(Value::as_str),
5370                            Some("thinking" | "redacted_thinking")
5371                        )
5372                    })
5373            });
5374    if reasoning_only {
5375        return;
5376    }
5377    let before = out.len();
5378    push_claude_assistant(&v, out);
5379    capture_claude_record_provenance(&v, &mut out[before..]);
5380    restore_single_grok_message(&v, &mut out[before..]);
5381}
5382
5383/// Attach the record identity, clock, and actual assistant model to every
5384/// canonical message produced from one Claude JSONL record. These fields are
5385/// deliberately per-message: a continued transcript can cross a provider
5386/// boundary, so the session-level source model is not authoritative for its
5387/// appended tail.
5388fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5389    let timestamp = v.get("timestamp").and_then(Value::as_str);
5390    let uuid = v.get("uuid").and_then(Value::as_str);
5391    let model = v
5392        .get("message")
5393        .and_then(|message| message.get("model"))
5394        .and_then(Value::as_str);
5395    for message in messages {
5396        if let Some(timestamp) = timestamp {
5397            message
5398                .metadata
5399                .entry("timestamp".to_string())
5400                .or_insert_with(|| timestamp.to_string());
5401        }
5402        if let Some(uuid) = uuid {
5403            message
5404                .metadata
5405                .entry("claude_uuid".to_string())
5406                .or_insert_with(|| uuid.to_string());
5407        }
5408        if let Some(model) = model {
5409            message
5410                .metadata
5411                .entry("model".to_string())
5412                .or_insert_with(|| model.to_string());
5413        }
5414    }
5415}
5416
5417fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5418    restore_codex_provenance_from_top_level(v, meta)?;
5419    if meta.session_id.is_none() {
5420        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5421            meta.session_id = Some(id.to_string());
5422        }
5423    }
5424    if meta.cwd.is_none() {
5425        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5426            meta.cwd = Some(PathBuf::from(cwd));
5427        }
5428    }
5429    if meta.model.is_none() {
5430        if let Some(model) = v
5431            .get("message")
5432            .and_then(|m| m.get("model"))
5433            .and_then(Value::as_str)
5434        {
5435            meta.model = Some(model.to_string());
5436        }
5437    }
5438    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5439    // real Claude Code record with no confirmed field shape (see
5440    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5441    // named fields and risk silently mis-modeling it, stash the WHOLE raw
5442    // line verbatim under a lineage key. `write_claude_code_records` (below)
5443    // re-emits it byte-for-byte, so the record survives the Claude Code
5444    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5445    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5446    // so a Claude -> Codex -> Claude round trip can still reconstruct it
5447    // (dev/03). A session can only fork from one context, so the first one
5448    // seen wins, matching every other "first wins" field above.
5449    //
5450    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5451    // a RE-SERIALIZATION of the parsed `Value`, not the original source
5452    // text. `serde_json::Value` here has no `preserve_order` feature (see
5453    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5454    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5455    // this very comment was false. Fixed the cheap+honest way: store the
5456    // caller's own already-verbatim source `raw_line` text instead of
5457    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5458    // (key order, spacing, everything) rather than merely
5459    // structurally-equivalent JSON.
5460    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5461        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5462    {
5463        meta.lineage.insert(
5464            "claude_fork_context_ref_raw".to_string(),
5465            raw_line.to_string(),
5466        );
5467    }
5468    Ok(())
5469}
5470
5471fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5472    let content = v.get("message").and_then(|m| m.get("content"));
5473    let provenance = claude_user_provenance(v);
5474    match content {
5475        Some(Value::String(s)) => {
5476            if !s.trim().is_empty() {
5477                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5478            }
5479        }
5480        Some(Value::Array(blocks)) => {
5481            let mut text = String::new();
5482            // IX-5: image blocks alongside/instead of text — collected
5483            // separately (never synthesized on a malformed shape, see
5484            // `claude_image_block_to_part`) so a multimodal user turn
5485            // survives as `content_parts` instead of the image silently
5486            // vanishing.
5487            let mut images: Vec<Value> = Vec::new();
5488            // D5: an `image` block whose `source` isn't base64/url (e.g. a
5489            // Files-API `{"source":{"type":"file","file_id":..}}`
5490            // reference) makes `claude_image_block_to_part` return `None` —
5491            // track that it was SEEN even though it couldn't be converted,
5492            // so an image-ONLY record (no text, no convertible image) isn't
5493            // silently dropped below (the same vanishing-record bug-class
5494            // PARITY-11 fixed for reasoning-only turns).
5495            let mut saw_unconvertible_image = false;
5496            for b in blocks {
5497                match b.get("type").and_then(Value::as_str) {
5498                    Some("text") => push_text(&mut text, b.get("text")),
5499                    Some("tool_result") => {
5500                        let id = b
5501                            .get("tool_use_id")
5502                            .and_then(Value::as_str)
5503                            .unwrap_or_default();
5504                        // PARITY-11 (nested images): `extract_tool_result_content`
5505                        // captures any `image` blocks nested inside this
5506                        // `tool_result` into `content_parts` (via
5507                        // `claude_image_block_to_part`, the same conversion the
5508                        // top-level `image` block path already uses) instead of
5509                        // flattening them to the bare `[image]` marker text the
5510                        // old `extract_tool_result` emitted — the everyday
5511                        // "Read a PNG / screenshot tool output" shape.
5512                        let (result, images) =
5513                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5514                        let mut msg = tool_message(id, result);
5515                        if !images.is_empty() {
5516                            // D-mix (Fable review, must-fix): `content_parts`
5517                            // is a self-contained contract — the pi writer
5518                            // (`pi_content_value`) reads ONLY `content_parts`
5519                            // for a `Role::Tool` message and never falls back
5520                            // to `msg.content`, so on a MIXED text+image
5521                            // tool_result a bare `content_parts: [image]`
5522                            // silently drops the sibling text on `convert
5523                            // --to pi` (a regression vs. the pre-PARITY-11
5524                            // baseline, which at least preserved the text).
5525                            // Prepend the text as part 0, exactly mirroring
5526                            // `pi_content_to_text_and_parts` and
5527                            // `push_opencode_user`'s identical
5528                            // self-contained-parts construction. `msg.content`
5529                            // keeps the text too (unchanged) for the writers
5530                            // that read text from `msg.content` and only scan
5531                            // `content_parts` for `image_url` entries
5532                            // (`claude_tool_result_content_value`,
5533                            // `codex_tool_output_text`, the opencode
5534                            // assistant writer) — those already filter
5535                            // strictly on `image_url`/text-typed lookups, so
5536                            // this text part is never double-counted.
5537                            let mut parts = Vec::new();
5538                            if let Some(t) = &msg.content {
5539                                if !t.is_empty() {
5540                                    parts.push(serde_json::json!({"type": "text", "text": t}));
5541                                }
5542                            }
5543                            parts.extend(images);
5544                            msg.content_parts = Some(parts);
5545                        }
5546                        // The assistant turn that issued this tool call — the
5547                        // tool-pairing graph edge (parallel to parentUuid).
5548                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5549                        {
5550                            msg.metadata
5551                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5552                        }
5553                        // TR-10: preserve the Claude wire `is_error` flag so
5554                        // the reduction layer's success/failure boundary
5555                        // (`ReductionKind::ToolInputElided` must never target
5556                        // an errored call) survives import — `ChatMessage`
5557                        // otherwise has no structural slot for it.
5558                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5559                            crate::mark_tool_error(&mut msg);
5560                        } else {
5561                            restore_tool_outcome_extension(v, &mut msg);
5562                        }
5563                        out.push(msg);
5564                    }
5565                    Some("image") => match claude_image_block_to_part(b) {
5566                        Some(part) => images.push(part),
5567                        None => saw_unconvertible_image = true,
5568                    },
5569                    _ => {} // document / unknown — skip
5570                }
5571            }
5572            // D5: nothing convertible landed in `text`/`images` but an
5573            // image block WAS present — fold in the same short bracketed
5574            // marker convention already used for `[web_search]`/`[model
5575            // fallback: ...]` rather than letting the record vanish.
5576            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5577                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5578            }
5579            let before = out.len();
5580            if !images.is_empty() {
5581                let mut parts = Vec::new();
5582                if !text.trim().is_empty() {
5583                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5584                }
5585                parts.extend(images);
5586                out.push(
5587                    ChatMessage {
5588                        role: Role::User,
5589                        content: None,
5590                        content_parts: Some(parts),
5591                        tool_calls: None,
5592                        tool_call_id: None,
5593                        name: None,
5594                        metadata: Default::default(),
5595                    }
5596                    .with_metas(&provenance),
5597                );
5598            } else if !text.trim().is_empty() {
5599                out.push(ChatMessage::user(text).with_metas(&provenance));
5600            }
5601            if saw_unconvertible_image && out.len() > before {
5602                if let Some(msg) = out.last_mut() {
5603                    msg.metadata
5604                        .insert("image_source_unconvertible".to_string(), "true".to_string());
5605                }
5606            }
5607        }
5608        _ => {}
5609    }
5610}
5611
5612/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5613/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5614/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5615/// else in the record survives either — matches the existing
5616/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5617/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5618const UNCONVERTIBLE_IMAGE_MARKER: &str =
5619    "[image: source not captured — unsupported/unconvertible image reference]";
5620
5621/// Parse a Claude Code user-turn `image` content block
5622/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5623/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5624/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5625/// bare URL for the url form) — the inverse of
5626/// [`claude_user_content_value`]'s emission. Only a well-formed source
5627/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5628/// anything else — including a well-formed but unconvertible source like a
5629/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5630/// residue rather than synthesizing a corrupt/empty part (mirrors the
5631/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5632/// discipline). Callers must not let that turn the record invisible though:
5633/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5634fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5635    let source = b.get("source")?;
5636    match source.get("type").and_then(Value::as_str) {
5637        Some("base64") => {
5638            let mime = source.get("media_type").and_then(Value::as_str)?;
5639            let data = source.get("data").and_then(Value::as_str)?;
5640            if mime.is_empty() || data.is_empty() {
5641                return None;
5642            }
5643            Some(serde_json::json!({
5644                "type": "image_url",
5645                "image_url": {"url": format!("data:{mime};base64,{data}")},
5646            }))
5647        }
5648        Some("url") => {
5649            let url = source.get("url").and_then(Value::as_str)?;
5650            if url.is_empty() {
5651                return None;
5652            }
5653            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5654        }
5655        _ => None,
5656    }
5657}
5658
5659/// Rebuild a Claude Code user-turn `message.content` value from a
5660/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5661/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5662/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5663/// overriding constraint: a text-only message's export stays byte-identical)
5664/// — only a multimodal message (`content_parts` present, e.g. imported from
5665/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5666/// content-array shape, one `text` block (if any non-empty text part) plus
5667/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5668/// any other URL → `source.url`).
5669fn claude_user_content_value(msg: &ChatMessage) -> Value {
5670    match &msg.content_parts {
5671        Some(parts) => {
5672            let mut blocks = Vec::new();
5673            for p in parts {
5674                match p.get("type").and_then(Value::as_str) {
5675                    Some("text") => {
5676                        if let Some(t) = p.get("text").and_then(Value::as_str) {
5677                            if !t.is_empty() {
5678                                blocks.push(serde_json::json!({"type": "text", "text": t}));
5679                            }
5680                        }
5681                    }
5682                    Some("image_url") => {
5683                        if let Some(url) = p
5684                            .get("image_url")
5685                            .and_then(|u| u.get("url"))
5686                            .and_then(Value::as_str)
5687                        {
5688                            blocks.push(match parse_data_uri(url) {
5689                                Some((mime, data)) => serde_json::json!({
5690                                    "type": "image",
5691                                    "source": {"type": "base64", "media_type": mime, "data": data},
5692                                }),
5693                                None => serde_json::json!({
5694                                    "type": "image",
5695                                    "source": {"type": "url", "url": url},
5696                                }),
5697                            });
5698                        }
5699                    }
5700                    _ => {}
5701                }
5702            }
5703            Value::Array(blocks)
5704        }
5705        None => Value::String(msg.content.clone().unwrap_or_default()),
5706    }
5707}
5708
5709/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5710/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5711/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5712/// the historical plain-string `content` exactly (same IX-5-style constraint
5713/// `claude_user_content_value` follows) — only a `tool_result` that actually
5714/// carries a captured nested image gets the Anthropic content-array shape,
5715/// one `text` block (the existing `msg.content`, if any) plus one `image`
5716/// block per `image_url` part (mirrors `claude_user_content_value`'s
5717/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5718fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5719    match &msg.content_parts {
5720        Some(parts) if !parts.is_empty() => {
5721            let mut blocks = Vec::new();
5722            if let Some(t) = &msg.content {
5723                if !t.is_empty() {
5724                    blocks.push(serde_json::json!({"type": "text", "text": t}));
5725                }
5726            }
5727            for p in parts {
5728                if p.get("type").and_then(Value::as_str) == Some("image_url") {
5729                    if let Some(url) = p
5730                        .get("image_url")
5731                        .and_then(|u| u.get("url"))
5732                        .and_then(Value::as_str)
5733                    {
5734                        blocks.push(match parse_data_uri(url) {
5735                            Some((mime, data)) => serde_json::json!({
5736                                "type": "image",
5737                                "source": {"type": "base64", "media_type": mime, "data": data},
5738                            }),
5739                            None => serde_json::json!({
5740                                "type": "image",
5741                                "source": {"type": "url", "url": url},
5742                            }),
5743                        });
5744                    }
5745                }
5746            }
5747            Value::Array(blocks)
5748        }
5749        _ => Value::String(msg.content.clone().unwrap_or_default()),
5750    }
5751}
5752
5753/// Collect the Claude Code user-turn provenance fields that distinguish real
5754/// human input from system-injected turns and record replay-relevant state.
5755pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5756    let mut out = Vec::new();
5757    let mut take_str = |key: &str| {
5758        if let Some(s) = v.get(key).and_then(Value::as_str) {
5759            out.push((key.to_string(), s.to_string()));
5760        }
5761    };
5762    take_str("promptSource"); // typed | queued | system | sdk
5763    take_str("interruptedMessageId");
5764    take_str("sourceToolUseID");
5765    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5766        if v.get(flag).and_then(Value::as_bool) == Some(true) {
5767            out.push((flag.to_string(), "true".to_string()));
5768        }
5769    }
5770    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5771        out.push(("queuePriority".to_string(), n.to_string()));
5772    }
5773    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5774    if let Some(kind) = v
5775        .get("origin")
5776        .and_then(|o| o.get("kind"))
5777        .and_then(Value::as_str)
5778    {
5779        out.push(("origin".to_string(), kind.to_string()));
5780    }
5781    out
5782}
5783
5784/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5785/// `local_command`, `away_summary`) carry real text that's part of the
5786/// interaction; fold them in as system context. Marker/metric subtypes
5787/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5788/// no conversational content and are skipped.
5789fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5790    let keep = matches!(
5791        v.get("subtype").and_then(Value::as_str),
5792        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5793    );
5794    if !keep {
5795        return;
5796    }
5797    if let Some(content) = v.get("content").and_then(Value::as_str) {
5798        if !content.trim().is_empty() {
5799            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5800            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5801        }
5802    }
5803}
5804
5805/// Fold content-bearing Claude Code `attachment` records into the conversation
5806/// as user-role messages. Most attachment subtypes (`task_reminder`,
5807/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5808/// are regenerable system injections and are skipped; only the four that carry
5809/// non-regenerable user/external content are kept.
5810fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5811    let att = match v.get("attachment") {
5812        Some(a) => a,
5813        None => return,
5814    };
5815    let kind = match att.get("type").and_then(Value::as_str) {
5816        Some(kind) => kind,
5817        None => return,
5818    };
5819    let text = match kind {
5820        // A queued prompt. `commandMode` says whose: `prompt` is the person's
5821        // own text, `task-notification` is the runtime reporting a finished
5822        // background task. Kept verbatim below.
5823        "queued_command" => att
5824            .get("prompt")
5825            .and_then(Value::as_str)
5826            .map(str::to_string),
5827        // A file the user attached: header + contents.
5828        "file" => attachment_with_path(att, "attached file", "filename", "content"),
5829        // A user-edited file snippet.
5830        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5831        // Injected project memory (CLAUDE.md), point-in-time.
5832        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5833        _ => None, // regenerable system injection — skip
5834    };
5835    let Some(text) = text else { return };
5836    if text.trim().is_empty() {
5837        return;
5838    }
5839    // An attachment record wears the user's ROLE, but the record itself says
5840    // who actually spoke — and that fact is lost the moment the attachment is
5841    // flattened to `[label: path]` text, so carry it as metadata the way
5842    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5843    //
5844    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
5845    //                     `nested_memory` are envelopes the runtime built
5846    //                     around a file body; a frontend that trusts the role
5847    //                     shows the reader a numbered source listing in a
5848    //                     chat bubble apparently sent by themselves.
5849    //   `commandMode`     present on `queued_command` only, and the whole
5850    //                     story for it. Measured over the local Claude Code
5851    //                     corpus (2,512 `queued_command` attachments): 926
5852    //                     `prompt`, every one of them plain human text, and
5853    //                     1,586 `task-notification`, every one of them a
5854    //                     `<task-notification>` frame — the same text Claude
5855    //                     Code also writes as a `type:"user"` record stamped
5856    //                     `origin.kind = "task-notification"`.
5857    //
5858    // Presentation policy (which of these a frontend hides) belongs to the
5859    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5860    // job is to stop discarding the producer's own answer.
5861    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5862    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5863        message = message.with_meta("commandMode", mode);
5864    }
5865    out.push(message);
5866}
5867
5868/// Format an attachment as `[<label>: <path>]\n<body>`.
5869fn attachment_with_path(
5870    att: &Value,
5871    label: &str,
5872    path_key: &str,
5873    body_key: &str,
5874) -> Option<String> {
5875    let body = att.get(body_key).and_then(Value::as_str)?;
5876    let path = att
5877        .get(path_key)
5878        .or_else(|| att.get("displayPath"))
5879        .and_then(Value::as_str)
5880        .unwrap_or("");
5881    Some(format!("[{label}: {path}]\n{body}"))
5882}
5883
5884fn push_str_field(buf: &mut String, s: &str) {
5885    if !buf.is_empty() {
5886        buf.push('\n');
5887    }
5888    buf.push_str(s);
5889}
5890
5891/// N3: build a synthesized message for reasoning that could not attach to a
5892/// following assistant turn — either interrupted mid-stream by a
5893/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5894/// the three pending buffers (all empty/`false` afterward) so callers don't
5895/// separately have to remember to clear them.
5896fn orphaned_reasoning_message(
5897    reasoning: &mut String,
5898    reasoning_content: &mut String,
5899    encrypted: &mut bool,
5900) -> ChatMessage {
5901    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5902    if !reasoning.is_empty() {
5903        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5904    }
5905    if !reasoning_content.is_empty() {
5906        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5907    }
5908    if *encrypted {
5909        msg = msg.with_meta("reasoning_encrypted", "true");
5910        *encrypted = false;
5911    }
5912    msg
5913}
5914
5915fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5916    let content = v.get("message").and_then(|m| m.get("content"));
5917    let mut text = String::new();
5918    let mut calls: Vec<ToolCall> = Vec::new();
5919    // Legacy singular fields — kept for backward compatibility with every
5920    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5921    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5922    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5923    // message carries MULTIPLE `thinking` blocks, collapsing them down to
5924    // these singular fields silently drops every signature but the last
5925    // one's — a real Anthropic `thinking` block's `signature` cryptographically
5926    // covers ONLY that block's own text, so re-emitting block 1's text under
5927    // block 2's signature (or vice versa) produces a signature that will
5928    // never verify. `thinking_blocks` below is the fix: every block
5929    // preserved SEPARATELY, in order, each with its own (optional)
5930    // signature/data — the writer prefers it over the legacy fields
5931    // whenever present.
5932    let mut thinking = String::new();
5933    let mut signature: Option<String> = None;
5934    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5935    // `image` assistant blocks, and (rarely) a `fallback` model-routing
5936    // marker — none handled before, all silently vanishing (audit's own
5937    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5938    // `fallback` blocks in the reference corpus).
5939    //
5940    // D8: `redacted_thinking` is real data ONLY — never a fabricated
5941    // placeholder. The pre-fix code defaulted a missing `data` field to the
5942    // literal string `"<redacted>"`, which is indistinguishable from an
5943    // actual (if oddly-named) opaque payload on re-emit — a caller reading
5944    // it back has no way to tell "no data was ever captured" from "the
5945    // provider's own opaque blob happens to be the string `<redacted>`".
5946    // `redacted_thinking_seen` tracks block PRESENCE independently of
5947    // whether it had real data, so the reasoning-only-turn rescue below
5948    // still fires even when no block had a `data` field at all.
5949    let mut redacted_thinking: Option<String> = None;
5950    let mut redacted_thinking_seen = false;
5951    let mut images: Vec<Value> = Vec::new();
5952    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
5953    // `thinking` string alongside a real `signature` (the summarized/
5954    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
5955    // would miss those, so track "a thinking block existed at all"
5956    // separately from whether it had visible text.
5957    let mut thinking_block_seen = false;
5958    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
5959    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
5960    // above. Serialized as a single JSON-array metadata string
5961    // (`ChatMessage::metadata` is a flat string map) under
5962    // `"thinking_blocks"`.
5963    let mut thinking_blocks: Vec<Value> = Vec::new();
5964    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
5965    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
5966    // not silently vanish the whole record when nothing else survives.
5967    let mut saw_unconvertible_image = false;
5968
5969    match content {
5970        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
5971        Some(Value::Array(blocks)) => {
5972            for b in blocks {
5973                match b.get("type").and_then(Value::as_str) {
5974                    Some("text") => push_text(&mut text, b.get("text")),
5975                    Some("tool_use") => {
5976                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
5977                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
5978                        let args = b
5979                            .get("input")
5980                            .map(|i| i.to_string())
5981                            .unwrap_or_else(|| "{}".to_string());
5982                        calls.push(function_call(id, name, args));
5983                    }
5984                    // Thinking is not replayed across providers, but retain it in
5985                    // (skip-serialized) metadata so a same-model continuation can
5986                    // re-inject it. See P3.
5987                    Some("thinking") => {
5988                        thinking_block_seen = true;
5989                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
5990                        if !t.is_empty() {
5991                            push_str_field(&mut thinking, t); // legacy concatenated field
5992                        }
5993                        let sig = b.get("signature").and_then(Value::as_str);
5994                        if let Some(s) = sig {
5995                            signature = Some(s.to_string()); // legacy last-wins field
5996                        }
5997                        // D8: this block's OWN text + signature, not folded
5998                        // into the running concatenation above.
5999                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
6000                        if let Some(s) = sig {
6001                            block["signature"] = Value::String(s.to_string());
6002                        }
6003                        thinking_blocks.push(block);
6004                    }
6005                    // Anthropic's redacted reasoning: an opaque, provider-private
6006                    // payload (flagged content the API declines to show in the
6007                    // clear). Like `thinking`, it's not replayable, but the raw
6008                    // `data` is retained in metadata rather than silently
6009                    // vanishing — a same-model continuation can still replay it
6010                    // verbatim even though supercode never renders it.
6011                    Some("redacted_thinking") => {
6012                        redacted_thinking_seen = true;
6013                        let data = b.get("data").and_then(Value::as_str);
6014                        // D8: no fabricated fallback — `data` is only ever
6015                        // the real captured payload, or genuinely absent.
6016                        if let Some(d) = data {
6017                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
6018                        }
6019                        let mut block = serde_json::json!({"type": "redacted_thinking"});
6020                        if let Some(d) = data {
6021                            block["data"] = Value::String(d.to_string());
6022                        }
6023                        thinking_blocks.push(block);
6024                    }
6025                    // An assistant-emitted image block (e.g. a generated
6026                    // image) — collected exactly like `push_claude_user`'s
6027                    // user-turn image handling (`claude_image_block_to_part`
6028                    // is role-general), so it survives as `content_parts`
6029                    // instead of vanishing.
6030                    Some("image") => match claude_image_block_to_part(b) {
6031                        Some(part) => images.push(part),
6032                        None => saw_unconvertible_image = true,
6033                    },
6034                    // A provider-routing note (real shape:
6035                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6036                    // — a mid-generation model swap, e.g. an overloaded model
6037                    // falling back to another). Carries no replayable
6038                    // conversational content, but folding it into `text` as a
6039                    // short bracketed marker — the same convention the Codex
6040                    // loader already uses for `[web_search]`/
6041                    // `[image_generation] ...` — keeps it visible instead of
6042                    // silently vanishing, including the case where it's the
6043                    // ONLY block in the turn (see the reasoning-only-turn fix
6044                    // below: before this, that shape dropped the entire
6045                    // message).
6046                    Some("fallback") => {
6047                        let from = b
6048                            .get("from")
6049                            .and_then(|f| f.get("model"))
6050                            .and_then(Value::as_str)
6051                            .unwrap_or("?");
6052                        let to = b
6053                            .get("to")
6054                            .and_then(|t| t.get("model"))
6055                            .and_then(Value::as_str)
6056                            .unwrap_or("?");
6057                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6058                    }
6059                    _ => {}
6060                }
6061            }
6062        }
6063        _ => {}
6064    }
6065
6066    // D5: nothing convertible landed in `text`/`images` but an image block
6067    // WAS present — fold in the same bracketed-marker convention `fallback`
6068    // uses above, so a genuinely image-only (unconvertible source) turn
6069    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6070    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6071        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6072    }
6073
6074    let before = out.len();
6075    if !images.is_empty() {
6076        let mut parts = Vec::new();
6077        if !text.trim().is_empty() {
6078            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6079        }
6080        parts.extend(images);
6081        out.push(ChatMessage {
6082            role: Role::Assistant,
6083            content: None,
6084            content_parts: Some(parts),
6085            tool_calls: (!calls.is_empty()).then_some(calls),
6086            tool_call_id: None,
6087            name: None,
6088            metadata: Default::default(),
6089        });
6090    } else {
6091        push_assistant(out, text, calls);
6092        // A recognized native assistant record remains transcript state even
6093        // when its content array is empty (for example, an interrupted model
6094        // turn). Force a bare message whenever `push_assistant` had nothing
6095        // to emit. This includes the reasoning-only case and also preserves
6096        // genuinely part-less records instead of silently changing turn
6097        // count/order during translation.
6098        if out.len() == before {
6099            let mut empty = ChatMessage {
6100                role: Role::Assistant,
6101                content: None,
6102                content_parts: None,
6103                tool_calls: None,
6104                tool_call_id: None,
6105                name: None,
6106                metadata: Default::default(),
6107            };
6108            if !thinking_block_seen && !redacted_thinking_seen {
6109                empty
6110                    .metadata
6111                    .insert("empty_assistant_record".to_string(), "true".to_string());
6112            }
6113            out.push(empty);
6114        }
6115    }
6116    // Attach retained reasoning + attribution to the message we just produced.
6117    if out.len() > before {
6118        if let Some(msg) = out.last_mut() {
6119            // Insert "thinking" (even as an empty string) whenever a
6120            // `thinking` block was actually seen, not just when it had
6121            // visible text — a real `thinking` block commonly carries an
6122            // empty `thinking` string alongside a real `signature` (the
6123            // summarized-away-but-still-replayable case), and the writer
6124            // below keys its re-emission decision off this metadata key's
6125            // PRESENCE, not its content.
6126            if thinking_block_seen {
6127                msg.metadata.insert("thinking".to_string(), thinking);
6128            }
6129            if let Some(sig) = signature {
6130                msg.metadata.insert("thinking_signature".to_string(), sig);
6131            }
6132            if let Some(rt) = redacted_thinking {
6133                msg.metadata.insert("redacted_thinking".to_string(), rt);
6134            }
6135            // D8: exact per-block re-emission list — every `thinking`/
6136            // `redacted_thinking` block preserved separately, in order, each
6137            // with its own (optional) signature/data. The writer prefers
6138            // this over the legacy singular fields above whenever present,
6139            // so a multi-block message round-trips losslessly instead of
6140            // collapsing to one block under one (now-unverifiable)
6141            // signature.
6142            if !thinking_blocks.is_empty() {
6143                msg.metadata.insert(
6144                    "thinking_blocks".to_string(),
6145                    Value::Array(thinking_blocks).to_string(),
6146                );
6147            }
6148            // D5: honest signal that this message contained an image block
6149            // whose source this loader couldn't convert — the actual image
6150            // content is NOT captured, only a marker/partial record.
6151            if saw_unconvertible_image {
6152                msg.metadata
6153                    .insert("image_source_unconvertible".to_string(), "true".to_string());
6154            }
6155            // Attribution: which skill / subagent / MCP server+tool produced
6156            // this turn, plus the model `slug`.
6157            for key in [
6158                "attributionSkill",
6159                "attributionAgent",
6160                "attributionMcpServer",
6161                "attributionMcpTool",
6162                "slug",
6163            ] {
6164                if let Some(s) = v.get(key).and_then(Value::as_str) {
6165                    msg.metadata.insert(key.to_string(), s.to_string());
6166                }
6167            }
6168        }
6169    }
6170}
6171
6172// ---- Codex ----------------------------------------------------------------
6173
6174const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6175
6176fn codex_provenance_kind(record: &Value) -> Option<&str> {
6177    match record.get("type").and_then(Value::as_str) {
6178        Some("session_meta") => Some("session_meta"),
6179        Some("turn_context") => Some("turn_context"),
6180        Some("compacted") => Some("compacted"),
6181        Some("event_msg") => match record
6182            .get("payload")
6183            .and_then(|payload| payload.get("type"))
6184            .and_then(Value::as_str)
6185        {
6186            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6187            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6188            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6189            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6190            _ => None,
6191        },
6192        _ => None,
6193    }
6194}
6195
6196fn capture_codex_provenance_record(
6197    meta: &mut SessionMeta,
6198    record_index: usize,
6199    raw_line: &str,
6200    record: &Value,
6201) {
6202    let Some(kind) = codex_provenance_kind(record) else {
6203        return;
6204    };
6205    meta.codex_provenance.push(serde_json::json!({
6206        "record_index": record_index,
6207        "kind": kind,
6208        "raw": raw_line,
6209    }));
6210}
6211
6212fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6213    (!meta.codex_provenance.is_empty()).then(|| {
6214        serde_json::json!({
6215            "version": 1,
6216            "records": &meta.codex_provenance,
6217        })
6218    })
6219}
6220
6221fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6222    if extension.get("version").and_then(Value::as_u64) != Some(1) {
6223        return Err(Error::InvalidSession(
6224            "invalid portable Codex provenance: expected version 1".to_string(),
6225        ));
6226    }
6227    let Some(records) = extension.get("records").and_then(Value::as_array) else {
6228        return Err(Error::InvalidSession(
6229            "invalid portable Codex provenance: `records` must be an array".to_string(),
6230        ));
6231    };
6232    if records.is_empty() {
6233        return Err(Error::InvalidSession(
6234            "invalid portable Codex provenance: `records` must not be empty".to_string(),
6235        ));
6236    }
6237    let mut restored = Vec::with_capacity(records.len());
6238    for entry in records {
6239        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6240            return Err(Error::InvalidSession(
6241                "invalid portable Codex provenance: record_index must be an integer".to_string(),
6242            ));
6243        };
6244        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6245            return Err(Error::InvalidSession(
6246                "invalid portable Codex provenance: kind must be a string".to_string(),
6247            ));
6248        };
6249        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6250            return Err(Error::InvalidSession(
6251                "invalid portable Codex provenance: raw must be a string".to_string(),
6252            ));
6253        };
6254        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6255            return Err(Error::InvalidSession(
6256                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6257            ));
6258        };
6259        if codex_provenance_kind(&record) != Some(kind) {
6260            return Err(Error::InvalidSession(format!(
6261                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6262            )));
6263        }
6264        restored.push(entry.clone());
6265    }
6266    meta.codex_provenance = restored;
6267    meta.codex_headers.clear();
6268    for entry in &meta.codex_provenance {
6269        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6270            continue;
6271        };
6272        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6273            continue;
6274        };
6275        if matches!(
6276            record.get("type").and_then(Value::as_str),
6277            Some("session_meta") | Some("turn_context")
6278        ) {
6279            meta.codex_headers.push(record);
6280        }
6281    }
6282    Ok(true)
6283}
6284
6285fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6286    match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6287        Some(extension) => restore_codex_provenance(extension, meta),
6288        None => Ok(false),
6289    }
6290}
6291
6292fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6293    let Some(line_end) = out.find('\n') else {
6294        return;
6295    };
6296    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6297        return;
6298    };
6299    let Some(object) = record.as_object_mut() else {
6300        return;
6301    };
6302    object.insert(key.to_string(), extension);
6303    out.replace_range(..line_end, &record.to_string());
6304}
6305
6306fn inject_codex_provenance(out: &mut String, extension: Value) {
6307    let Some(line_end) = out.find('\n') else {
6308        return;
6309    };
6310    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6311        return;
6312    };
6313    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6314        return;
6315    }
6316    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6317        return;
6318    };
6319    payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6320    out.replace_range(..line_end, &record.to_string());
6321}
6322
6323/// Remove the last conversational turn from `messages`: everything from the
6324/// last `user` message to the end (the user prompt plus the assistant's
6325/// response and any tool calls/results it triggered).
6326fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6327    if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6328        messages.truncate(idx);
6329    } else {
6330        messages.clear();
6331    }
6332    // IX-6 fix: the new tail exposed by `truncate` may still carry
6333    // `__codex_open_turn` from when it was marked (it was NOT the last
6334    // message at that time — items after it, now removed by the rollback,
6335    // intervened). A bare `function_call` arriving after the rollback is a
6336    // genuinely NEW turn and must get its own message, not merge into this
6337    // stale marked tail — close it out here so `push_codex_item`'s
6338    // adjacency check (`out.last()` + marker) can't be fooled by the
6339    // truncation re-exposing it.
6340    if let Some(last) = messages.last_mut() {
6341        last.metadata.remove("__codex_open_turn");
6342    }
6343}
6344
6345fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6346    truncate_messages_with_anchor(messages, message_limit, None);
6347}
6348
6349fn truncate_messages_with_anchor(
6350    messages: &mut Vec<ChatMessage>,
6351    message_limit: usize,
6352    preceding_user: Option<ChatMessage>,
6353) {
6354    let limit = message_limit.max(1);
6355    if messages.len() <= limit {
6356        return;
6357    }
6358    let tail_start = messages.len() - limit;
6359    if let Some(relative_user) = messages[tail_start..]
6360        .iter()
6361        .position(|message| message.role == Role::User)
6362    {
6363        messages.drain(..tail_start + relative_user);
6364        return;
6365    }
6366    let anchor = messages[..tail_start]
6367        .iter()
6368        .rfind(|message| message.role == Role::User)
6369        .cloned()
6370        .or(preceding_user);
6371    if let Some(anchor) = anchor {
6372        let recent_start = messages.len() - limit.saturating_sub(1);
6373        messages.drain(..recent_start);
6374        messages.insert(0, anchor);
6375    } else {
6376        messages.drain(..tail_start);
6377    }
6378}
6379
6380fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6381    truncate_messages(&mut session.messages, message_limit);
6382}
6383
6384/// The text of a Codex `agent_message` event. `message` is usually a string but
6385/// can be a structured object (e.g. review output) — fall back to its JSON.
6386fn agent_message_text(payload: &Value) -> String {
6387    match payload.get("message") {
6388        Some(Value::String(s)) => s.clone(),
6389        Some(other) => extract_text_content(Some(other)),
6390        None => String::new(),
6391    }
6392}
6393
6394/// Trimmed texts of all assistant messages present as `response_item` — the
6395/// dedup set for recovering collab-only `agent_message` narration.
6396fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6397    let mut set = std::collections::HashSet::new();
6398    for line in non_empty_lines(jsonl) {
6399        let Ok(v) = serde_json::from_str::<Value>(line) else {
6400            continue;
6401        };
6402        if v.get("type").and_then(Value::as_str) != Some("response_item") {
6403            continue;
6404        }
6405        let payload = v.get("payload").unwrap_or(&Value::Null);
6406        if payload.get("type").and_then(Value::as_str) == Some("message")
6407            && payload.get("role").and_then(Value::as_str) == Some("assistant")
6408        {
6409            let text = extract_text_content(payload.get("content"));
6410            if !text.trim().is_empty() {
6411                set.insert(text.trim().to_string());
6412            }
6413        }
6414    }
6415    set
6416}
6417
6418fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6419    if meta.session_id.is_none() {
6420        if let Some(id) = payload.get("id").and_then(Value::as_str) {
6421            meta.session_id = Some(id.to_string());
6422        }
6423    }
6424    if meta.cwd.is_none() {
6425        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6426            meta.cwd = Some(PathBuf::from(cwd));
6427        }
6428    }
6429    if meta.system_prompt.is_none() {
6430        // `base_instructions` may be a string or `{ "text": "..." }`.
6431        let bi = payload.get("base_instructions");
6432        let text = match bi {
6433            Some(Value::String(s)) => Some(s.clone()),
6434            Some(Value::Object(_)) => bi
6435                .and_then(|b| b.get("text"))
6436                .and_then(Value::as_str)
6437                .map(str::to_string),
6438            _ => None,
6439        };
6440        meta.system_prompt = text;
6441    }
6442    if meta.model.is_none() {
6443        if let Some(m) = payload.get("model").and_then(Value::as_str) {
6444            meta.model = Some(m.to_string());
6445        }
6446    }
6447    // Cross-file lineage keys for multi-agent / forked sessions.
6448    let mut put = |key: &str, v: Option<&Value>| {
6449        if let Some(s) = v.and_then(Value::as_str) {
6450            meta.lineage.insert(key.to_string(), s.to_string());
6451        }
6452    };
6453    put("parent_thread_id", payload.get("parent_thread_id"));
6454    put("forked_from_id", payload.get("forked_from_id"));
6455    put("thread_source", payload.get("thread_source"));
6456    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6457    // passthrough — restores a captured Claude `fork-context-ref` so a
6458    // Claude -> Codex -> Claude round trip reconstructs the original record
6459    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6460    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6461        if let Some(v) = payload.get("claude_fork_context_ref") {
6462            meta.lineage
6463                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6464        }
6465    }
6466    if let Some(spawn) = payload
6467        .get("source")
6468        .and_then(|s| s.get("subagent"))
6469        .and_then(|s| s.get("thread_spawn"))
6470    {
6471        // parent_thread_id can also live here (preferred when both present).
6472        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6473            meta.lineage
6474                .insert("parent_thread_id".to_string(), p.to_string());
6475        }
6476        for k in ["agent_role", "agent_nickname"] {
6477            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6478                meta.lineage.insert(k.to_string(), s.to_string());
6479            }
6480        }
6481        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6482            meta.lineage.insert("depth".to_string(), d.to_string());
6483        }
6484    }
6485}
6486
6487/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6488fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6489    let mut d = 0;
6490    let mut guard = 0;
6491    while let Some(p) = parent_of[i] {
6492        if p == i || guard > parent_of.len() {
6493            break;
6494        }
6495        i = p;
6496        d += 1;
6497        guard += 1;
6498    }
6499    d
6500}
6501
6502/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6503fn codex_turn_id(payload: &Value) -> Option<&str> {
6504    payload
6505        .get("metadata")
6506        .and_then(|m| m.get("turn_id"))
6507        .and_then(Value::as_str)
6508}
6509
6510/// N2 (spliced-export hardening): every Codex group id already present in
6511/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6512/// replays ahead of the appended tail it synthesizes via
6513/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6514/// physically lands in the exported `out` string for the prefix: each line
6515/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6516/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6517/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6518/// export) is extracted directly — no re-derivation from `self.messages`
6519/// needed (that would have to reconstruct which ids the ORIGINAL export
6520/// happened to assign, which this sidesteps entirely by reading them back
6521/// out of the bytes themselves). A line that fails to parse, isn't a
6522/// `response_item`, or carries no `turn_id` contributes nothing — headers
6523/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6524/// never carry this field to begin with.
6525fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6526    let mut ids = HashSet::new();
6527    for line in raw_prefix {
6528        if let Ok(v) = serde_json::from_str::<Value>(line) {
6529            if let Some(payload) = v.get("payload") {
6530                if let Some(tid) = codex_turn_id(payload) {
6531                    ids.insert(tid.to_string());
6532                }
6533            }
6534        }
6535    }
6536    ids
6537}
6538
6539/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6540/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6541/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6542/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6543/// message that already carries a more specific timestamp of its own is
6544/// never overwritten (none currently do on the Codex side, but this keeps
6545/// every loader consistent). A no-op when `ts` is `None` (a line with no
6546/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6547fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6548    let Some(ts) = ts else { return };
6549    let Some(slice) = messages.get_mut(from..) else {
6550        return;
6551    };
6552    for m in slice {
6553        m.metadata
6554            .entry("timestamp".to_string())
6555            .or_insert_with(|| ts.to_string());
6556    }
6557}
6558
6559fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6560    match payload.get("type").and_then(Value::as_str) {
6561        Some("message") => {
6562            let role = match payload.get("role").and_then(Value::as_str) {
6563                Some("user") => Role::User,
6564                Some("assistant") => Role::Assistant,
6565                // "developer" and "system" both carry operator instructions.
6566                _ => Role::System,
6567            };
6568            let content = payload.get("content");
6569            let text = extract_text_content(content);
6570            // IX-5: `input_image` blocks alongside/instead of text — see
6571            // `codex_extract_images`. A text-only message (no image blocks)
6572            // takes the historical `content: Some(text)` shape unchanged.
6573            let images = codex_extract_images(content);
6574            let is_empty_assistant =
6575                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6576            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6577                let content_parts = if images.is_empty() {
6578                    None
6579                } else {
6580                    let mut parts = Vec::new();
6581                    if !text.trim().is_empty() {
6582                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6583                    }
6584                    parts.extend(images);
6585                    Some(parts)
6586                };
6587                let mut msg = ChatMessage {
6588                    role,
6589                    content: if content_parts.is_some() || text.is_empty() {
6590                        None
6591                    } else {
6592                        Some(text)
6593                    },
6594                    content_parts,
6595                    tool_calls: None,
6596                    tool_call_id: None,
6597                    name: None,
6598                    metadata: Default::default(),
6599                };
6600                // Preserve the assistant `phase` (commentary vs final_answer) so
6601                // a reloaded transcript can distinguish narration from the answer.
6602                if role == Role::Assistant {
6603                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6604                        msg.metadata.insert("phase".to_string(), phase.to_string());
6605                    }
6606                    // IX-6: mark this as an open, mergeable combined-turn
6607                    // candidate — a `function_call` response_item found
6608                    // immediately after (still `out.last()` when reached,
6609                    // i.e. no other item intervened) merges into this SAME
6610                    // `ChatMessage` instead of splitting into a second one,
6611                    // matching how Claude's parser keeps a text+tool_use
6612                    // turn together. Stripped again before the loaded
6613                    // `Session` is returned (`from_codex_str`), so it never
6614                    // leaks as visible metadata.
6615                    msg.metadata
6616                        .insert("__codex_open_turn".to_string(), "true".to_string());
6617                }
6618                // The per-turn grouping key (Codex batches items by turn_id).
6619                if let Some(tid) = codex_turn_id(payload) {
6620                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
6621                }
6622                // PARITY-6 dev/02: restore the original Claude
6623                // `systemSubtype` for a `developer`/`system` message that
6624                // was itself synthesized FROM a real Claude system record
6625                // (`write_codex_records`'s `Role::System` arm stamps
6626                // `claude_system_subtype`) — the exact inverse, so
6627                // `write_claude_code_records`'s `Role::System` arm can
6628                // re-materialize the real Claude `type: "system"` record
6629                // faithfully on a Codex -> Claude Code hop instead of
6630                // guessing a fallback subtype.
6631                if role == Role::System {
6632                    if let Some(subtype) = payload
6633                        .get("metadata")
6634                        .and_then(|m| m.get("claude_system_subtype"))
6635                        .and_then(Value::as_str)
6636                    {
6637                        msg.metadata
6638                            .insert("systemSubtype".to_string(), subtype.to_string());
6639                    }
6640                }
6641                if is_empty_assistant {
6642                    msg.metadata
6643                        .insert("empty_assistant_record".to_string(), "true".to_string());
6644                }
6645                out.push(msg);
6646            }
6647        }
6648        Some("function_call") => {
6649            let id = payload
6650                .get("call_id")
6651                .and_then(Value::as_str)
6652                .unwrap_or_default();
6653            let raw_name = payload
6654                .get("name")
6655                .and_then(Value::as_str)
6656                .unwrap_or_default();
6657            // Preserve the MCP `namespace` by qualifying the tool name
6658            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6659            // so the tool identity isn't ambiguous on round-trip.
6660            let qualified;
6661            let name = match payload.get("namespace").and_then(Value::as_str) {
6662                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6663                    qualified = format!("{ns}__{raw_name}");
6664                    qualified.as_str()
6665                }
6666                _ => raw_name,
6667            };
6668            let args = payload
6669                .get("arguments")
6670                .map(value_to_arg_string)
6671                .unwrap_or_else(|| "{}".to_string());
6672            let call = function_call(id, name, args);
6673            // IX-6: a `function_call` immediately after an assistant `message`
6674            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6675            // by the "message" arm above, and not yet closed by anything else)
6676            // merges into that ONE `ChatMessage` — text→`content`,
6677            // call→`tool_calls` — instead of splitting into a second message.
6678            // A bare `function_call` with no such preceding turn (the marker
6679            // absent, or `out.last()` not an assistant message) is unaffected:
6680            // it still gets its own synthesized message, exactly as before.
6681            //
6682            // Belt-and-suspenders (PARITY-6/7 tightened): if this
6683            // `function_call` response_item itself carries a `turn_id` (rare
6684            // in observed real-native-Codex corpora — Codex usually only
6685            // stamps it on `message` payloads — but ALWAYS present on OUR
6686            // OWN synthesized export whenever a `ChatMessage`'s own tool
6687            // calls need merge disambiguation, see `write_codex_records`),
6688            // it must match the marked assistant message's recorded
6689            // `turn_id` EXACTLY — including "the marked message has none at
6690            // all" counting as a mismatch. That's exactly the shape of two
6691            // genuinely separate, adjacent `ChatMessage`s (an unrelated
6692            // text-only turn immediately followed by a different,
6693            // tool-call-only turn): the tool-only turn's own `function_call`s
6694            // carry a synthetic id while the unrelated preceding text
6695            // message carries none, so this correctly refuses the merge
6696            // instead of falling through to a permissive default. Only when
6697            // this `function_call` carries NO `turn_id` at all (the ordinary
6698            // real-native-Codex shape) does this fall back to the original
6699            // permissive "adjacency + open marker is enough" rule —
6700            // unchanged from before for the vast majority of real Codex
6701            // data. The truncation/clear strip above is what actually closes
6702            // the marker across rollback/compaction boundaries; this is only
6703            // an extra guard for the case where a stale-but-unstripped
6704            // marker and a turn_id mismatch coincide.
6705            let can_merge = out.last().is_some_and(|last| {
6706                last.role == Role::Assistant
6707                    && last.metadata.contains_key("__codex_open_turn")
6708                    && match codex_turn_id(payload) {
6709                        Some(fc_tid) => {
6710                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6711                        }
6712                        None => true,
6713                    }
6714            });
6715            if can_merge {
6716                out.last_mut()
6717                    .expect("can_merge implies out.last() is Some")
6718                    .tool_calls
6719                    .get_or_insert_with(Vec::new)
6720                    .push(call);
6721            } else {
6722                push_assistant(out, String::new(), vec![call]);
6723                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6724                // in this turn, so nothing set `__codex_open_turn` above) can
6725                // still be the FIRST of several tool calls that all belong to
6726                // the SAME original `ChatMessage` (`write_codex_records`
6727                // stamps every one of a message's own tool calls with the
6728                // identical synthetic `turn_id`). Re-open THIS freshly
6729                // created message — but ONLY when a real `turn_id` is
6730                // present — so the NEXT `function_call` in the same group
6731                // merges into it instead of becoming its own message too.
6732                // Gated on `codex_turn_id(payload).is_some()` (not the bare
6733                // default `true` the belt-and-suspenders check above uses)
6734                // so real native Codex data — which almost never carries
6735                // this field on `function_call` payloads (see the comment
6736                // above) — keeps its existing "every bare tool call is its
6737                // own turn" behavior exactly as before.
6738                if let Some(tid) = codex_turn_id(payload) {
6739                    if let Some(last) = out.last_mut() {
6740                        last.metadata
6741                            .insert("__codex_open_turn".to_string(), "true".to_string());
6742                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6743                    }
6744                }
6745            }
6746        }
6747        Some("function_call_output") => {
6748            let id = payload
6749                .get("call_id")
6750                .and_then(Value::as_str)
6751                .unwrap_or_default();
6752            let result = match payload.get("output") {
6753                Some(Value::String(s)) => s.clone(),
6754                Some(v) => extract_text_content(Some(v)),
6755                None => String::new(),
6756            };
6757            let mut message = tool_message(id, result);
6758            // TR-13: Codex v1 exposes no structured success/error field on
6759            // this record. Free-text output is not a safe classifier, so the
6760            // reduction engine must treat the outcome as explicitly unknown
6761            // and fail closed on both success-only and error-only pruning.
6762            crate::mark_tool_outcome_unknown(&mut message);
6763            out.push(message);
6764        }
6765        // Custom / MCP tool calls are shaped like function calls but carry their
6766        // arguments under `input` (a JSON-encoded string). Normalize them the
6767        // same way so MCP-using sessions don't lose those turns.
6768        Some("custom_tool_call") => {
6769            let id = payload
6770                .get("call_id")
6771                .and_then(Value::as_str)
6772                .unwrap_or_default();
6773            let name = payload
6774                .get("name")
6775                .and_then(Value::as_str)
6776                .unwrap_or_default();
6777            // Unlike `function_call.arguments`, Codex custom tools accept a
6778            // free-form `input` string (apply_patch is the common case).
6779            // Canonical `FunctionCall::arguments` must remain valid JSON, so
6780            // retain the input's JSON type instead of treating a free-form
6781            // string as if it were already a JSON document. This lets every
6782            // target harness carry the value rather than silently replacing
6783            // it with `{}` when `parsed_arguments()` fails.
6784            let args = payload
6785                .get("input")
6786                .map(Value::to_string)
6787                .unwrap_or_else(|| "{}".to_string());
6788            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6789            if let Some(message) = out.last_mut() {
6790                message.metadata.insert(
6791                    "codex_custom_tool_call_ids".to_string(),
6792                    serde_json::json!([id]).to_string(),
6793                );
6794            }
6795        }
6796        Some("custom_tool_call_output") => {
6797            let id = payload
6798                .get("call_id")
6799                .and_then(Value::as_str)
6800                .unwrap_or_default();
6801            let result = match payload.get("output") {
6802                Some(Value::String(s)) => s.clone(),
6803                Some(v) => extract_text_content(Some(v)),
6804                None => String::new(),
6805            };
6806            let mut message = tool_message(id, result);
6807            crate::mark_tool_outcome_unknown(&mut message);
6808            out.push(message);
6809        }
6810        // Tool-search is a clean call/output pair keyed by call_id.
6811        //
6812        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6813        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6814        // its own synthesized `tool_search_call` records (see the PARITY-6/7
6815        // comment there and on `codex_turn_id`/the `function_call` arm
6816        // above). That left the same bug-class the turn_id work fixed for
6817        // `function_call` half-done here: a single Claude assistant message
6818        // containing text + a `tool_search` block reloaded as 2 messages
6819        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6820        // reloaded as 3. Mirror the `function_call` arm's merge check
6821        // exactly so a `tool_search_call` immediately following an open
6822        // assistant turn (or another tool call sharing the same `turn_id`)
6823        // merges into that SAME `ChatMessage` instead of splitting.
6824        Some("tool_search_call") => {
6825            let id = payload
6826                .get("call_id")
6827                .and_then(Value::as_str)
6828                .unwrap_or_default();
6829            let args = payload
6830                .get("arguments")
6831                .map(value_to_arg_string)
6832                .unwrap_or_else(|| "{}".to_string());
6833            let call = function_call(id, "tool_search", args);
6834            let can_merge = out.last().is_some_and(|last| {
6835                last.role == Role::Assistant
6836                    && last.metadata.contains_key("__codex_open_turn")
6837                    && match codex_turn_id(payload) {
6838                        Some(fc_tid) => {
6839                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6840                        }
6841                        None => true,
6842                    }
6843            });
6844            if can_merge {
6845                out.last_mut()
6846                    .expect("can_merge implies out.last() is Some")
6847                    .tool_calls
6848                    .get_or_insert_with(Vec::new)
6849                    .push(call);
6850            } else {
6851                push_assistant(out, String::new(), vec![call]);
6852                // Re-open the freshly created message so a FOLLOWING
6853                // `function_call`/`tool_search_call` sharing this same
6854                // `turn_id` merges into it too — matching the bare
6855                // `function_call` case's own re-open logic above.
6856                if let Some(tid) = codex_turn_id(payload) {
6857                    if let Some(last) = out.last_mut() {
6858                        last.metadata
6859                            .insert("__codex_open_turn".to_string(), "true".to_string());
6860                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6861                    }
6862                }
6863            }
6864        }
6865        Some("tool_search_output") => {
6866            let id = payload
6867                .get("call_id")
6868                .and_then(Value::as_str)
6869                .unwrap_or_default();
6870            let result = payload
6871                .get("tools")
6872                .map(value_to_arg_string)
6873                .unwrap_or_default();
6874            out.push(tool_message(id, result));
6875        }
6876        // Web-search / image-generation response_items carry no paired output
6877        // here (results live in event_msg), so emit an assistant marker rather
6878        // than a dangling unanswered tool call.
6879        Some("web_search_call") => {
6880            push_assistant(out, "[web_search]".to_string(), Vec::new());
6881        }
6882        Some("image_generation_call") => {
6883            let prompt = payload
6884                .get("revised_prompt")
6885                .and_then(Value::as_str)
6886                .unwrap_or("");
6887            push_assistant(
6888                out,
6889                format!("[image_generation] {prompt}").trim().to_string(),
6890                Vec::new(),
6891            );
6892        }
6893        // "reasoning" and anything else — dropped.
6894        _ => {}
6895    }
6896}
6897
6898// ---- Grok -------------------------------------------------------------
6899
6900const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6901
6902fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
6903    value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
6904        "schema": 1,
6905        "role": message.role,
6906        "content": message.content,
6907        "content_parts": message.content_parts,
6908        "tool_calls": message.tool_calls,
6909        "tool_call_id": message.tool_call_id,
6910        "name": message.name,
6911        "metadata": message.metadata,
6912    });
6913}
6914
6915fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
6916    let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
6917        return;
6918    };
6919    if extension.get("schema").and_then(Value::as_u64) != Some(1) {
6920        return;
6921    }
6922    if let Some(role) = extension
6923        .get("role")
6924        .and_then(|value| serde_json::from_value(value.clone()).ok())
6925    {
6926        message.role = role;
6927    }
6928    message.content = extension
6929        .get("content")
6930        .and_then(Value::as_str)
6931        .map(str::to_string);
6932    message.content_parts = extension
6933        .get("content_parts")
6934        .and_then(|value| serde_json::from_value(value.clone()).ok());
6935    message.tool_calls = extension
6936        .get("tool_calls")
6937        .and_then(|value| serde_json::from_value(value.clone()).ok());
6938    message.tool_call_id = extension
6939        .get("tool_call_id")
6940        .and_then(Value::as_str)
6941        .map(str::to_string);
6942    message.name = extension
6943        .get("name")
6944        .and_then(Value::as_str)
6945        .map(str::to_string);
6946    message.metadata.clear();
6947    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
6948        for (key, value) in metadata {
6949            if let Some(value) = value.as_str() {
6950                message.metadata.insert(key.clone(), value.to_string());
6951            }
6952        }
6953    }
6954}
6955
6956fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
6957    for key in keys {
6958        if let Some(value) = value.get(*key) {
6959            message.metadata.insert(
6960                format!("grok_{key}"),
6961                value
6962                    .as_str()
6963                    .map(str::to_string)
6964                    .unwrap_or_else(|| value.to_string()),
6965            );
6966        }
6967    }
6968}
6969
6970fn grok_human_user_text(raw: &str) -> Option<String> {
6971    let text = raw.trim();
6972    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
6973        return None;
6974    }
6975    let unwrapped = text
6976        .strip_prefix("<user_query>")
6977        .and_then(|value| value.strip_suffix("</user_query>"))
6978        .map(str::trim)
6979        .unwrap_or(text);
6980    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
6981}
6982
6983/// Portable extension for messages whose canonical fields cannot be expressed
6984/// by the target's stock schema. It was introduced for Grok and retains that
6985/// on-disk key for compatibility. Gemini has the same need: Claude Code and
6986/// Codex have no native slot for a tool-result name or Gemini-only metadata.
6987/// Their readers tolerate unknown namespaced fields, so forwarding this
6988/// adapter-owned envelope keeps those cross-format hops reversible without
6989/// pretending the stock schemas represent the fields directly.
6990const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
6991
6992/// Namespaced line-level extension carrying the one tool-result outcome state
6993/// Claude cannot represent natively. Keeping this narrower than the full Grok
6994/// portability envelope avoids changing unrelated target-message projection.
6995const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
6996
6997fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
6998    if !crate::is_tool_error(message)
6999        && value
7000            .get(SUPERCODE_TOOL_OUTCOME_KEY)
7001            .and_then(Value::as_str)
7002            == Some("unknown")
7003    {
7004        crate::mark_tool_outcome_unknown(message);
7005    }
7006}
7007
7008fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
7009    let metadata = message
7010        .metadata
7011        .iter()
7012        .filter(|(key, _)| {
7013            key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
7014        })
7015        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
7016        .collect::<serde_json::Map<_, _>>();
7017
7018    // `meta.source` changes after every reload. Keying portability only on
7019    // the immediate source therefore made Grok metadata survive one hop but
7020    // disappear on A -> B -> C translations. Once Grok-owned fields are
7021    // present, keep forwarding them regardless of the current container.
7022    let has_portable_fields =
7023        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7024    (matches!(
7025        source,
7026        SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7027    ) || has_portable_fields
7028        || message.content_parts.is_some())
7029    .then(|| {
7030        serde_json::json!({
7031            "schema": 2,
7032            "role": message.role,
7033            "content": message.content,
7034            "content_parts": message.content_parts,
7035            "tool_calls": message.tool_calls,
7036            "tool_call_id": message.tool_call_id,
7037            "name": message.name,
7038            "metadata": message.metadata,
7039        })
7040    })
7041}
7042
7043fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7044    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7045        "schema": 2,
7046        "role": message.role,
7047        "content": message.content,
7048        "content_parts": message.content_parts,
7049        "tool_calls": message.tool_calls,
7050        "tool_call_id": message.tool_call_id,
7051        "name": message.name,
7052        "metadata": message.metadata,
7053    });
7054}
7055
7056fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7057    if let Some(extension) = grok_message_extension(source, message) {
7058        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7059    }
7060}
7061
7062fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7063    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7064        return;
7065    };
7066    // Codex temporarily marks a text assistant item so immediately-following
7067    // function-call items can merge back into the same canonical turn. The
7068    // portable envelope must not erase that loader-private marker before the
7069    // merge happens; `from_codex_str` removes it before returning.
7070    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7071    let codex_turn_id = message.metadata.get("turn_id").cloned();
7072    let extension_has_turn_id = extension
7073        .get("metadata")
7074        .and_then(Value::as_object)
7075        .is_some_and(|metadata| metadata.contains_key("turn_id"));
7076    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7077        if let Some(role) = extension
7078            .get("role")
7079            .and_then(|value| serde_json::from_value(value.clone()).ok())
7080        {
7081            message.role = role;
7082        }
7083        message.content = extension
7084            .get("content")
7085            .and_then(Value::as_str)
7086            .map(str::to_string);
7087        message.content_parts = extension
7088            .get("content_parts")
7089            .and_then(|value| serde_json::from_value(value.clone()).ok());
7090        // Tool calls are shared native structure in every supported format.
7091        // Keep the loader's reconstruction instead of restoring this copy:
7092        // Codex stores a combined text+tool turn across multiple records, so
7093        // eagerly restoring calls on its text record would duplicate them
7094        // when the following function-call records merge.
7095        message.tool_call_id = extension
7096            .get("tool_call_id")
7097            .and_then(Value::as_str)
7098            .map(str::to_string);
7099        message.name = extension
7100            .get("name")
7101            .and_then(Value::as_str)
7102            .map(str::to_string);
7103        message.metadata.clear();
7104    }
7105    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7106        for (key, value) in metadata {
7107            if let Some(value) = value.as_str() {
7108                message.metadata.insert(key.clone(), value.to_string());
7109            }
7110        }
7111    }
7112    if let Some(name) = extension.get("name").and_then(Value::as_str) {
7113        message.name = Some(name.to_string());
7114    }
7115    if let Some(marker) = codex_open_turn {
7116        message
7117            .metadata
7118            .insert("__codex_open_turn".to_string(), marker);
7119    }
7120    if let Some(turn_id) = codex_turn_id {
7121        message.metadata.insert("turn_id".to_string(), turn_id);
7122        if !extension_has_turn_id {
7123            message.metadata.insert(
7124                "__grok_remove_synthetic_turn_id".to_string(),
7125                "true".to_string(),
7126            );
7127        }
7128    }
7129}
7130
7131fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7132    if let [message] = messages {
7133        restore_grok_message_extension(value, message);
7134    }
7135}
7136
7137fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7138    let role = match native.get("role").and_then(Value::as_str) {
7139        Some("assistant") => Role::Assistant,
7140        _ => Role::User,
7141    };
7142    let created = native.get("created").and_then(Value::as_i64);
7143    let native_id = native.get("id").and_then(Value::as_str);
7144    let mut text = Vec::new();
7145    let mut content_parts = Vec::new();
7146    let mut tool_calls = Vec::new();
7147    let mut tool_results = Vec::new();
7148
7149    for (block_index, block) in native
7150        .get("content")
7151        .and_then(Value::as_array)
7152        .into_iter()
7153        .flatten()
7154        .enumerate()
7155    {
7156        match block.get("type").and_then(Value::as_str) {
7157            Some("text") => {
7158                if let Some(value) = block.get("text").and_then(Value::as_str) {
7159                    text.push(value.to_string());
7160                    content_parts.push(serde_json::json!({"type": "text", "text": value}));
7161                }
7162            }
7163            Some("image") => {
7164                let data = block
7165                    .get("data")
7166                    .and_then(Value::as_str)
7167                    .unwrap_or_default();
7168                let media_type = block
7169                    .get("mimeType")
7170                    .or_else(|| block.get("mime_type"))
7171                    .and_then(Value::as_str)
7172                    .unwrap_or("application/octet-stream");
7173                content_parts.push(serde_json::json!({
7174                    "type": "image_url",
7175                    "image_url": {"url": format!("data:{media_type};base64,{data}")},
7176                }));
7177            }
7178            Some("toolRequest" | "frontendToolRequest") => {
7179                let id = block
7180                    .get("id")
7181                    .and_then(Value::as_str)
7182                    .map(str::to_string)
7183                    .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7184                let call = block
7185                    .get("toolCall")
7186                    .and_then(|call| {
7187                        (call.get("status").and_then(Value::as_str) == Some("success"))
7188                            .then(|| call.get("value"))
7189                            .flatten()
7190                    })
7191                    .or_else(|| block.get("toolCall"));
7192                let Some(call) = call else { continue };
7193                let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7194                let arguments = call
7195                    .get("arguments")
7196                    .map(value_to_arg_string)
7197                    .unwrap_or_else(|| "{}".to_string());
7198                tool_calls.push(function_call(&id, name, arguments));
7199            }
7200            Some("toolResponse") => tool_results.push(block.clone()),
7201            _ => {}
7202        }
7203    }
7204
7205    if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7206        let has_non_text = content_parts
7207            .iter()
7208            .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7209        let mut message = ChatMessage {
7210            role,
7211            content: (!text.is_empty()).then(|| text.join("\n")),
7212            content_parts: has_non_text.then_some(content_parts),
7213            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7214            tool_call_id: None,
7215            name: None,
7216            metadata: Default::default(),
7217        };
7218        capture_goose_message_metadata(native, created, native_id, &mut message);
7219        out.push(message);
7220    }
7221
7222    for (result_index, block) in tool_results.into_iter().enumerate() {
7223        let id = block
7224            .get("id")
7225            .and_then(Value::as_str)
7226            .map(str::to_string)
7227            .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7228        let result = block.get("toolResult").unwrap_or(&Value::Null);
7229        let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7230        let value = result.get("value").unwrap_or(result);
7231        let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7232        let output = if status_error {
7233            result
7234                .get("error")
7235                .and_then(Value::as_str)
7236                .unwrap_or("Goose tool call failed")
7237                .to_string()
7238        } else {
7239            value
7240                .get("content")
7241                .and_then(Value::as_array)
7242                .map(|content| {
7243                    content
7244                        .iter()
7245                        .filter_map(|part| {
7246                            part.get("text")
7247                                .and_then(Value::as_str)
7248                                .map(str::to_string)
7249                                .or_else(|| Some(part.to_string()))
7250                        })
7251                        .collect::<Vec<_>>()
7252                        .join("\n")
7253                })
7254                .unwrap_or_else(|| value.to_string())
7255        };
7256        let mut message = tool_message(&id, output);
7257        if is_error {
7258            crate::mark_tool_error(&mut message);
7259        }
7260        capture_goose_message_metadata(native, created, native_id, &mut message);
7261        out.push(message);
7262    }
7263}
7264
7265fn capture_goose_message_metadata(
7266    native: &Value,
7267    created: Option<i64>,
7268    native_id: Option<&str>,
7269    message: &mut ChatMessage,
7270) {
7271    if let Some(created) = created {
7272        message
7273            .metadata
7274            .insert("goose_created".to_string(), created.to_string());
7275    }
7276    if let Some(native_id) = native_id {
7277        message
7278            .metadata
7279            .insert("goose_message_id".to_string(), native_id.to_string());
7280    }
7281    if let Some(metadata) = native.get("metadata") {
7282        message
7283            .metadata
7284            .insert("goose_metadata".to_string(), metadata.to_string());
7285    }
7286}
7287
7288#[doc(hidden)]
7289pub fn percent_decode_path(encoded: &str) -> Option<String> {
7290    fn hex(byte: u8) -> Option<u8> {
7291        match byte {
7292            b'0'..=b'9' => Some(byte - b'0'),
7293            b'a'..=b'f' => Some(byte - b'a' + 10),
7294            b'A'..=b'F' => Some(byte - b'A' + 10),
7295            _ => None,
7296        }
7297    }
7298
7299    let bytes = encoded.as_bytes();
7300    let mut decoded = Vec::with_capacity(bytes.len());
7301    let mut index = 0usize;
7302    while index < bytes.len() {
7303        if bytes[index] == b'%' {
7304            let high = *bytes.get(index + 1)?;
7305            let low = *bytes.get(index + 2)?;
7306            decoded.push(hex(high)? * 16 + hex(low)?);
7307            index += 3;
7308        } else {
7309            decoded.push(bytes[index]);
7310            index += 1;
7311        }
7312    }
7313    String::from_utf8(decoded).ok()
7314}
7315
7316// ---- Pi ---------------------------------------------------------------
7317
7318fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7319    restore_codex_provenance_from_top_level(v, meta)?;
7320    if let Some(id) = v.get("id").and_then(Value::as_str) {
7321        meta.session_id = Some(id.to_string());
7322    }
7323    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7324        meta.cwd = Some(PathBuf::from(cwd));
7325    }
7326    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7327    let version = v
7328        .get("version")
7329        .and_then(Value::as_u64)
7330        .map(|n| n.to_string())
7331        .unwrap_or_else(|| "1".to_string());
7332    meta.lineage.insert("pi_version".to_string(), version);
7333    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7334        meta.lineage
7335            .insert("created_at".to_string(), ts.to_string());
7336    }
7337    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7338        meta.lineage
7339            .insert("parent_session_path".to_string(), ps.to_string());
7340    }
7341    // D7: the other half of `push_pi_header`'s passthrough — restores a
7342    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7343    // trip reconstructs the original record (mirrors
7344    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7345    // restore for the Codex hop).
7346    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7347        if let Some(v) = v.get("claude_fork_context_ref") {
7348            meta.lineage
7349                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7350        }
7351    }
7352    Ok(())
7353}
7354
7355/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7356/// `(mime, data)` when it looks like a real image payload.
7357///
7358/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7359/// `ai:316-350` for the `ImageContent` content-block union but does not
7360/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7361/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7362/// Anthropic multimodal wire shape) is this loader's best guess, not a
7363/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7364/// against a real pi corpus. Until then this function VALIDATES rather than
7365/// assumes: both fields must be present, non-empty strings, and `data` must
7366/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7367/// else is an unknown/unexpected image shape, and the caller must route the
7368/// whole message to raw-only survival (S6-style fail loud) instead of
7369/// silently synthesizing a corrupt/empty `image_url` part.
7370fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7371    let mime = item.get("mimeType").and_then(Value::as_str)?;
7372    let data = item.get("data").and_then(Value::as_str)?;
7373    if mime.is_empty() || data.is_empty() {
7374        return None;
7375    }
7376    if !data
7377        .bytes()
7378        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7379    {
7380        return None;
7381    }
7382    Some((mime.to_string(), data.to_string()))
7383}
7384
7385/// True if `content` (a pi content value: bare string or
7386/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7387/// that does not match [`pi_image_shape`] — shared by the loader (which
7388/// routes such a message to raw-only survival, never a synthesized-empty
7389/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7390/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7391/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7392#[doc(hidden)]
7393pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7394    let Some(Value::Array(items)) = content else {
7395        return false;
7396    };
7397    items.iter().any(|item| {
7398        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7399    })
7400}
7401
7402/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7403/// into concatenated text plus, when a WELL-FORMED image block is present,
7404/// the full `content_parts` array (leading text block + one `image_url` part
7405/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7406/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7407/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7408///
7409/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7410/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7411/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7412/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7413/// every caller must treat that as raw-only survival for the whole message
7414/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7415/// guessed wrong fails loud instead of silently dropping/corrupting the
7416/// image.
7417fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7418    match content {
7419        Some(Value::String(s)) => (s.clone(), None, false),
7420        Some(Value::Array(items)) => {
7421            let mut text = String::new();
7422            let mut parts: Vec<Value> = Vec::new();
7423            let mut has_image = false;
7424            let mut unknown_image_shape = false;
7425            for item in items {
7426                match item.get("type").and_then(Value::as_str) {
7427                    Some("text") => {
7428                        if let Some(t) = item.get("text").and_then(Value::as_str) {
7429                            push_str_field(&mut text, t);
7430                        }
7431                    }
7432                    Some("image") => {
7433                        has_image = true;
7434                        match pi_image_shape(item) {
7435                            Some((mime, data)) => {
7436                                parts.push(serde_json::json!({
7437                                    "type": "image_url",
7438                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
7439                                }));
7440                            }
7441                            None => unknown_image_shape = true,
7442                        }
7443                    }
7444                    _ => {}
7445                }
7446            }
7447            if unknown_image_shape {
7448                // Never synthesize an empty/corrupt part for a shape we
7449                // don't recognize — raw-only survival for the whole message;
7450                // the coverage guard is what turns this into a visible
7451                // failure (S6-style).
7452                return (String::new(), None, true);
7453            }
7454            if has_image {
7455                if !text.trim().is_empty() {
7456                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7457                }
7458                (text, Some(parts), false)
7459            } else {
7460                (text, None, false)
7461            }
7462        }
7463        _ => (String::new(), None, false),
7464    }
7465}
7466
7467fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7468    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7469    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7470    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7471    // `message/UnknownImageShape` bucket is what turns this into a visible
7472    // coverage failure.
7473    if unknown_image_shape {
7474        return;
7475    }
7476    if text.trim().is_empty() && parts.is_none() {
7477        return;
7478    }
7479    let mut msg = match parts {
7480        Some(parts) => ChatMessage {
7481            role: Role::User,
7482            content: None,
7483            content_parts: Some(parts),
7484            tool_calls: None,
7485            tool_call_id: None,
7486            name: None,
7487            metadata: Default::default(),
7488        },
7489        None => ChatMessage::user(text),
7490    };
7491    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7492    // (`message.timestamp`) is a DISTINCT field from the canonical
7493    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7494    // carry genuinely different values in real corpora (the fixture's are
7495    // ~6 months apart). Preserve it separately so it isn't silently lost for
7496    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7497    // native round-trip consumer) and the INHERENT residue note on
7498    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7499    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7500        msg.metadata
7501            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7502    }
7503    out.push(msg);
7504}
7505
7506fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7507    let mut text = String::new();
7508    let mut calls: Vec<ToolCall> = Vec::new();
7509    let mut thinking = String::new();
7510    let mut thinking_seen = false;
7511    let mut thinking_sig: Option<String> = None;
7512    let mut thinking_redacted = false;
7513    let mut text_sig: Option<String> = None;
7514    let mut thought_sig: Option<String> = None;
7515
7516    if let Some(Value::Array(blocks)) = msg_v.get("content") {
7517        for b in blocks {
7518            match b.get("type").and_then(Value::as_str) {
7519                Some("text") => {
7520                    if let Some(t) = b.get("text").and_then(Value::as_str) {
7521                        push_str_field(&mut text, t);
7522                    }
7523                    if let Some(sig) = b.get("textSignature") {
7524                        text_sig = Some(match sig {
7525                            Value::String(s) => s.clone(),
7526                            other => other.to_string(),
7527                        });
7528                    }
7529                }
7530                Some("thinking") => {
7531                    thinking_seen = true;
7532                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7533                        push_str_field(&mut thinking, t);
7534                    }
7535                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7536                        thinking_sig = Some(sig.to_string());
7537                    }
7538                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7539                        thinking_redacted = true;
7540                    }
7541                }
7542                Some("toolCall") => {
7543                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7544                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7545                    // `arguments` is a JSON OBJECT on pi's wire, not a string
7546                    // (`pi-fields.md` §3b open question 4) — serialize to the
7547                    // string `FunctionCall::arguments` expects.
7548                    let args = b
7549                        .get("arguments")
7550                        .cloned()
7551                        .unwrap_or_else(|| Value::Object(Default::default()));
7552                    calls.push(function_call(id, name, args.to_string()));
7553                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7554                        thought_sig = Some(sig.to_string());
7555                    }
7556                }
7557                _ => {}
7558            }
7559        }
7560    }
7561
7562    let before = out.len();
7563    push_assistant(out, text, calls);
7564    // A recognized native assistant entry remains transcript state even
7565    // when its content array is empty, except Pi's explicit empty error
7566    // response: that record has no replayable content and is established
7567    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7568    // non-error turns and Pi's standalone thinking-block shape.
7569    let is_empty_error =
7570        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7571    if out.len() == before && !is_empty_error {
7572        let mut empty = ChatMessage {
7573            role: Role::Assistant,
7574            content: None,
7575            content_parts: None,
7576            tool_calls: None,
7577            tool_call_id: None,
7578            name: None,
7579            metadata: Default::default(),
7580        };
7581        if !thinking_seen {
7582            empty
7583                .metadata
7584                .insert("empty_assistant_record".to_string(), "true".to_string());
7585        }
7586        out.push(empty);
7587    }
7588    if out.len() > before {
7589        let msg = out.last_mut().expect("just pushed");
7590        if thinking_seen {
7591            msg.metadata.insert("thinking".to_string(), thinking);
7592        }
7593        if let Some(s) = thinking_sig {
7594            msg.metadata.insert("thinking_signature".to_string(), s);
7595        }
7596        if thinking_redacted {
7597            msg.metadata
7598                .insert("pi_thinking_redacted".to_string(), "true".to_string());
7599        }
7600        if let Some(s) = text_sig {
7601            msg.metadata.insert("pi_text_signature".to_string(), s);
7602        }
7603        if let Some(s) = thought_sig {
7604            msg.metadata.insert("pi_thought_signature".to_string(), s);
7605        }
7606        for (key, field) in [
7607            ("pi_api", "api"),
7608            ("pi_provider", "provider"),
7609            ("pi_response_model", "responseModel"),
7610            ("pi_response_id", "responseId"),
7611            ("pi_stop_reason", "stopReason"),
7612            ("pi_error_message", "errorMessage"),
7613        ] {
7614            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7615                msg.metadata.insert(key.to_string(), s.to_string());
7616            }
7617        }
7618        if let Some(diag) = msg_v.get("diagnostics") {
7619            if !diag.is_null() {
7620                msg.metadata
7621                    .insert("pi_diagnostics".to_string(), diag.to_string());
7622            }
7623        }
7624        if let Some(usage) = msg_v.get("usage") {
7625            if !usage.is_null() {
7626                msg.metadata
7627                    .insert("pi_usage".to_string(), usage.to_string());
7628            }
7629        }
7630        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7631        // separately from the canonical entry-level ISO `timestamp` — see
7632        // `push_pi_user`.
7633        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7634            msg.metadata
7635                .insert("pi_msg_timestamp".to_string(), ts.to_string());
7636        }
7637    }
7638}
7639
7640fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7641    let id = msg_v
7642        .get("toolCallId")
7643        .and_then(Value::as_str)
7644        .unwrap_or_default();
7645    let name = msg_v
7646        .get("toolName")
7647        .and_then(Value::as_str)
7648        .unwrap_or_default();
7649    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7650    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7651    // survival, never a synthesized-empty part. Dropping the toolResult
7652    // message here leaves its `toolCallId` unanswered, which
7653    // `ensure_tool_results_paired` already turns into a visible
7654    // "[no tool result recorded — turn interrupted]" placeholder — a loud
7655    // failure mode, not a silent one.
7656    if unknown_image_shape {
7657        return;
7658    }
7659    let mut msg = ChatMessage {
7660        role: Role::Tool,
7661        content: Some(text),
7662        content_parts: parts,
7663        tool_calls: None,
7664        tool_call_id: Some(id.to_string()),
7665        name: Some(name.to_string()),
7666        metadata: Default::default(),
7667    };
7668    if let Some(details) = msg_v.get("details") {
7669        if !details.is_null() {
7670            msg.metadata
7671                .insert("pi_tool_details".to_string(), details.to_string());
7672        }
7673    }
7674    let is_error = msg_v
7675        .get("isError")
7676        .and_then(Value::as_bool)
7677        .unwrap_or(false);
7678    msg.metadata
7679        .insert("pi_is_error".to_string(), is_error.to_string());
7680    if is_error {
7681        crate::mark_tool_error(&mut msg);
7682    }
7683    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7684    // separately from the canonical entry-level ISO `timestamp` — see
7685    // `push_pi_user`.
7686    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7687        msg.metadata
7688            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7689    }
7690    out.push(msg);
7691}
7692
7693/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7694/// pi itself sends the model, mirroring `bashExecutionToText`
7695/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7696/// aren't reproduced in the frozen research doc (only cited by file:line),
7697/// so this is a faithful, clearly-labeled reconstruction — every structured
7698/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7699fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7700    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7701    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7702    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7703    let cancelled = msg_v
7704        .get("cancelled")
7705        .and_then(Value::as_bool)
7706        .unwrap_or(false);
7707    let truncated = msg_v
7708        .get("truncated")
7709        .and_then(Value::as_bool)
7710        .unwrap_or(false);
7711
7712    let mut text = format!("$ {command}\n{output}");
7713    if let Some(code) = exit_code {
7714        if code != 0 {
7715            text.push_str(&format!("\n[exit code: {code}]"));
7716        }
7717    }
7718    if cancelled {
7719        text.push_str("\n[cancelled]");
7720    }
7721    if truncated {
7722        text.push_str("\n[truncated]");
7723    }
7724
7725    let mut msg = ChatMessage::user(text);
7726    msg.metadata
7727        .insert("pi_bash_command".to_string(), command.to_string());
7728    msg.metadata
7729        .insert("pi_bash_output".to_string(), output.to_string());
7730    if let Some(code) = exit_code {
7731        msg.metadata
7732            .insert("pi_bash_exit_code".to_string(), code.to_string());
7733    }
7734    msg.metadata
7735        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7736    msg.metadata
7737        .insert("pi_bash_truncated".to_string(), truncated.to_string());
7738    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7739        msg.metadata
7740            .insert("pi_bash_full_output_path".to_string(), p.to_string());
7741    }
7742    // `!!` — hidden from the model context; honored by `is_replay_excluded`
7743    // on every writer, not just pi's own (§2.2).
7744    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7745        msg.metadata
7746            .insert("pi_exclude_from_context".to_string(), "true".to_string());
7747    }
7748    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7749    // separately from the canonical entry-level ISO `timestamp` — see
7750    // `push_pi_user`.
7751    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7752        msg.metadata
7753            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7754    }
7755    out.push(msg);
7756}
7757
7758/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7759/// stamps on a re-materialized content-bearing Claude `system` record (see
7760/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7761/// never collide with a real pi `CustomMessage.customType` — pi's own
7762/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7763/// migration targets), never this literal string.
7764const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7765
7766/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7767/// `custom_message` entries (§9) — both enter context as a `User` message
7768/// with the same `customType`/`display`/`details` residue.
7769///
7770/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7771/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7772/// actually a re-materialized content-bearing Claude `system` record round-
7773/// tripping through pi, not a genuine pi extension message — restore
7774/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7775/// claude_system_subtype`, falling back to `local_command` — still one of
7776/// `push_claude_system`'s own keep subtypes — exactly like
7777/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7778/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7779/// the exact original role, not just the text.
7780fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7781    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7782        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7783        if content.trim().is_empty() {
7784            return;
7785        }
7786        let subtype = v
7787            .get("details")
7788            .and_then(|d| d.get("claude_system_subtype"))
7789            .and_then(Value::as_str)
7790            .unwrap_or("local_command");
7791        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7792        return;
7793    }
7794    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7795    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7796    // survival, never a synthesized-empty part.
7797    if unknown_image_shape {
7798        return;
7799    }
7800    if text.trim().is_empty() && parts.is_none() {
7801        return;
7802    }
7803    let mut msg = match parts {
7804        Some(parts) => ChatMessage {
7805            role: Role::User,
7806            content: None,
7807            content_parts: Some(parts),
7808            tool_calls: None,
7809            tool_call_id: None,
7810            name: None,
7811            metadata: Default::default(),
7812        },
7813        None => ChatMessage::user(text),
7814    };
7815    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7816        msg.metadata
7817            .insert("pi_custom_type".to_string(), ct.to_string());
7818    }
7819    if let Some(d) = v.get("display").and_then(Value::as_bool) {
7820        msg.metadata.insert("pi_display".to_string(), d.to_string());
7821    }
7822    if let Some(details) = v.get("details") {
7823        if !details.is_null() {
7824            msg.metadata
7825                .insert("pi_details".to_string(), details.to_string());
7826        }
7827    }
7828    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7829    // separately from the canonical entry-level ISO `timestamp` — see
7830    // `push_pi_user`. `v` here is the `message` object for the `role:
7831    // "custom"` case; for the top-level `custom_message` case `v` is the
7832    // entry itself, whose `timestamp` is the entry-level ISO string (not a
7833    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7834    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7835        msg.metadata
7836            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7837    }
7838    out.push(msg);
7839}
7840
7841/// pi's own prefix-wrapped user text for a `compaction` entry summary
7842/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7843/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7844/// reproduced in the frozen research doc; this is a clearly-labeled
7845/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7846fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7847    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7848    if summary.trim().is_empty() {
7849        return;
7850    }
7851    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7852    msg.metadata
7853        .insert("pi_type".to_string(), "compaction".to_string());
7854    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7855        msg.metadata
7856            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7857    }
7858    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7859        msg.metadata
7860            .insert("pi_tokens_before".to_string(), tb.to_string());
7861    }
7862    if let Some(d) = entry_v.get("details") {
7863        if !d.is_null() {
7864            msg.metadata.insert("pi_details".to_string(), d.to_string());
7865        }
7866    }
7867    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7868        msg.metadata
7869            .insert("pi_from_hook".to_string(), "true".to_string());
7870    }
7871    out.push(msg);
7872}
7873
7874/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7875/// rewind-with-summary) — same reconstruction caveat as
7876/// [`push_pi_compaction`].
7877fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7878    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7879    if summary.trim().is_empty() {
7880        return;
7881    }
7882    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
7883    msg.metadata
7884        .insert("pi_type".to_string(), "branch_summary".to_string());
7885    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7886        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7887    }
7888    if let Some(d) = entry_v.get("details") {
7889        if !d.is_null() {
7890            msg.metadata.insert("pi_details".to_string(), d.to_string());
7891        }
7892    }
7893    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7894        msg.metadata
7895            .insert("pi_from_hook".to_string(), "true".to_string());
7896    }
7897    out.push(msg);
7898}
7899
7900// ---- OpenCode ---------------------------------------------------------
7901
7902/// The placeholder opencode's own replay substitutes for a `tool` part's
7903/// output once `state.completed.time.compacted` is set
7904/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
7905/// erased from the record (S1); it survives in `raw` and in this loader's
7906/// `metadata["oc_tool_output_compacted"]`.
7907pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
7908
7909fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
7910    restore_codex_provenance_from_top_level(si, meta)?;
7911    if let Some(id) = si.get("id").and_then(Value::as_str) {
7912        meta.session_id = Some(id.to_string());
7913    }
7914    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
7915        meta.cwd = Some(PathBuf::from(dir));
7916    }
7917    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
7918        meta.agent_id = Some(agent.to_string());
7919    }
7920    if let Some(model) = si.get("model") {
7921        let provider = model.get("providerID").and_then(Value::as_str);
7922        let id = model.get("id").and_then(Value::as_str);
7923        if let (Some(p), Some(i)) = (provider, id) {
7924            meta.model = Some(format!("{p}/{i}"));
7925        }
7926    }
7927    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
7928        meta.lineage
7929            .insert("projectID".to_string(), project_id.to_string());
7930    }
7931    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
7932        meta.lineage.insert("slug".to_string(), slug.to_string());
7933    }
7934    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
7935        meta.lineage
7936            .insert("workspaceID".to_string(), ws.to_string());
7937    }
7938    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
7939        meta.lineage
7940            .insert("parent_session_id".to_string(), parent.to_string());
7941        // Mirrored under the Codex-originated lineage key so the existing
7942        // generic `Session::reconstruct_tree` nests opencode subagent
7943        // sessions too, with no format-specific nesting pass (§2.1: "child
7944        // session's parentID ... → drives reconstruct_tree").
7945        meta.lineage
7946            .insert("parent_thread_id".to_string(), parent.to_string());
7947    }
7948    // D7: the other half of `synthesized_opencode_info`'s passthrough —
7949    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
7950    // -> Claude round trip reconstructs the original record (mirrors
7951    // `capture_codex_session_meta`/`capture_pi_header`'s identical
7952    // `claude_fork_context_ref` restore for the Codex/Pi hops).
7953    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7954        if let Some(v) = si.get("claude_fork_context_ref") {
7955            meta.lineage
7956                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7957        }
7958    }
7959    Ok(())
7960}
7961
7962/// An opencode `User`/`Assistant` `file` part's image data-URI →
7963/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
7964/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
7965/// a bare filesystem path, an `https:` link, or a non-image mime is left as
7966/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
7967/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
7968/// coverage with the SAME test this loader uses to canonicalize it (D5) —
7969/// one definition of "is this file part actually replayed", not two.
7970#[doc(hidden)]
7971pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
7972    let mime = part.get("mime").and_then(Value::as_str)?;
7973    let url = part.get("url").and_then(Value::as_str)?;
7974    if !mime.starts_with("image/") || !url.starts_with("data:") {
7975        return None;
7976    }
7977    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
7978}
7979
7980/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
7981/// `Role::System` arm stamps on the one `synthetic: true` text part of a
7982/// re-materialized content-bearing Claude `system` record (see that arm's
7983/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
7984/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
7985const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
7986
7987/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
7988/// `User` message with EXACTLY one `synthetic: true` text part carrying
7989/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
7990/// opencode data is never misclassified — a genuine opencode `synthetic`
7991/// text part never carries this supercode-namespaced key, and a real
7992/// multi-part user message (text + an attached file, say) never matches
7993/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
7994/// (e.g. `local_command`) on a match.
7995fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
7996    let [part] = parts else { return None };
7997    if part.get("type").and_then(Value::as_str) != Some("text") {
7998        return None;
7999    }
8000    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
8001        return None;
8002    }
8003    part.get("metadata")
8004        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
8005        .and_then(Value::as_str)
8006        .map(str::to_string)
8007}
8008
8009/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
8010/// and `metadata["systemSubtype"]` from the marked text part instead of
8011/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
8012/// OpenCode -> Claude round trip restores the exact original role, not just
8013/// the text. Content is never fabricated — only emitted when non-empty.
8014fn push_opencode_claude_system(
8015    msg_value: &Value,
8016    parts: &[Value],
8017    subtype: String,
8018    out: &mut Vec<ChatMessage>,
8019) {
8020    let Some(text) = parts
8021        .first()
8022        .and_then(|p| p.get("text"))
8023        .and_then(Value::as_str)
8024    else {
8025        return;
8026    };
8027    if text.trim().is_empty() {
8028        return;
8029    }
8030    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8031    set_opencode_msg_timestamp(&mut msg, msg_value);
8032    out.push(msg);
8033}
8034
8035/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8036/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8037/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8038/// the model"); `file` parts with a recognized image shape become
8039/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8040/// `SessionMeta.system_prompt` on the first turn that carries it, and
8041/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8042/// per-user-message, not per-session").
8043/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8044/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8045/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8046/// (opencode's own wire granularity); a `None`/malformed `time.created`
8047/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8048/// `SYNTH_TS`/`SYNTH_TS_MS`.
8049fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8050    if let Some(ms) = msg_value
8051        .get("time")
8052        .and_then(|t| t.get("created"))
8053        .and_then(Value::as_i64)
8054    {
8055        msg.metadata
8056            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8057    }
8058}
8059
8060fn push_opencode_user(
8061    msg_value: &Value,
8062    parts: &[Value],
8063    out: &mut Vec<ChatMessage>,
8064    meta: &mut SessionMeta,
8065    first_system_seen: &mut bool,
8066) {
8067    let mut text = String::new();
8068    let mut image_parts: Vec<Value> = Vec::new();
8069    let mut has_ignored = false;
8070    for p in parts {
8071        match p.get("type").and_then(Value::as_str) {
8072            Some("text") => {
8073                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8074                    has_ignored = true;
8075                    continue; // must never be replayed (§2.2)
8076                }
8077                if let Some(t) = p.get("text").and_then(Value::as_str) {
8078                    push_str_field(&mut text, t);
8079                }
8080            }
8081            Some("file") => {
8082                if let Some(img) = opencode_file_image_part(p) {
8083                    image_parts.push(img);
8084                }
8085            }
8086            // reasoning/tool never appear on a User message; step-start,
8087            // step-finish, snapshot, patch, agent, subtask, retry have no
8088            // clean home (§2.3); compaction is read separately by the
8089            // caller (tail_start_id) and tagged onto the message below.
8090            _ => {}
8091        }
8092    }
8093
8094    let has_images = !image_parts.is_empty();
8095    if text.trim().is_empty() && !has_images {
8096        return;
8097    }
8098    let mut msg = if has_images {
8099        let mut all = Vec::new();
8100        if !text.trim().is_empty() {
8101            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8102        }
8103        all.extend(image_parts);
8104        ChatMessage {
8105            role: Role::User,
8106            content: None,
8107            content_parts: Some(all),
8108            tool_calls: None,
8109            tool_call_id: None,
8110            name: None,
8111            metadata: Default::default(),
8112        }
8113    } else {
8114        ChatMessage::user(text)
8115    };
8116
8117    if has_ignored {
8118        msg.metadata
8119            .insert("oc_has_ignored_part".to_string(), "true".to_string());
8120    }
8121    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8122        msg.metadata
8123            .insert("oc_message_id".to_string(), id.to_string());
8124    }
8125    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8126        msg.metadata.insert("agent".to_string(), agent.to_string());
8127    }
8128    if let Some(model) = msg_value.get("model") {
8129        if !model.is_null() {
8130            msg.metadata.insert("model".to_string(), model.to_string());
8131        }
8132    }
8133    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8134        if !*first_system_seen {
8135            meta.system_prompt = Some(system.to_string());
8136            *first_system_seen = true;
8137        }
8138        msg.metadata
8139            .insert("system".to_string(), system.to_string());
8140    }
8141    for p in parts {
8142        if p.get("type").and_then(Value::as_str) == Some("compaction") {
8143            msg.metadata
8144                .insert("phase".to_string(), "compaction".to_string());
8145            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8146                msg.metadata
8147                    .insert("tail_start_id".to_string(), t.to_string());
8148            }
8149        }
8150    }
8151    set_opencode_msg_timestamp(&mut msg, msg_value);
8152    restore_grok_message_extension(msg_value, &mut msg);
8153    out.push(msg);
8154}
8155
8156/// Map an opencode `Assistant` message + its parts to a canonical
8157/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8158/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8159/// reached `completed`/`error` — the split-by-`callID` opencode's single
8160/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8161/// interrupted turn) synthesize no tool call/result of their own here; the
8162/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8163/// like the other three loaders. A `tool` part whose `state.status` is none
8164/// of the four known values is skipped entirely — raw-only survival, never
8165/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8166fn push_opencode_assistant(
8167    msg_value: &Value,
8168    parts: &[Value],
8169    out: &mut Vec<ChatMessage>,
8170    meta: &mut SessionMeta,
8171) {
8172    let mut text = String::new();
8173    let mut calls: Vec<ToolCall> = Vec::new();
8174    let mut thinking = String::new();
8175    let mut reasoning_seen = false;
8176    let mut thinking_sig: Option<String> = None;
8177    // (call_id, tool_name, the tool part itself) — deferred so the
8178    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8179    // every other loader's message ordering (call, then result).
8180    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8181
8182    for p in parts {
8183        match p.get("type").and_then(Value::as_str) {
8184            Some("text") => {
8185                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8186                    continue;
8187                }
8188                if let Some(t) = p.get("text").and_then(Value::as_str) {
8189                    push_str_field(&mut text, t);
8190                }
8191            }
8192            Some("reasoning") => {
8193                reasoning_seen = true;
8194                if let Some(t) = p.get("text").and_then(Value::as_str) {
8195                    push_str_field(&mut thinking, t);
8196                }
8197                if let Some(sig) = p
8198                    .get("metadata")
8199                    .and_then(|m| m.get("anthropic"))
8200                    .and_then(|a| a.get("signature"))
8201                    .and_then(Value::as_str)
8202                {
8203                    thinking_sig = Some(sig.to_string());
8204                }
8205            }
8206            Some("tool") => {
8207                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8208                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8209                let status = p
8210                    .get("state")
8211                    .and_then(|s| s.get("status"))
8212                    .and_then(Value::as_str);
8213                let known_status = matches!(
8214                    status,
8215                    Some("pending") | Some("running") | Some("completed") | Some("error")
8216                );
8217                if call_id.is_empty() || !known_status {
8218                    // Unknown/unrecognized status, or a malformed part with
8219                    // no callID — raw-only survival, never synthesized.
8220                    continue;
8221                }
8222                let input = p
8223                    .get("state")
8224                    .and_then(|s| s.get("input"))
8225                    .cloned()
8226                    .unwrap_or_else(|| Value::Object(Default::default()));
8227                calls.push(function_call(call_id, tool_name, input.to_string()));
8228                if matches!(status, Some("completed") | Some("error")) {
8229                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8230                }
8231            }
8232            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8233            // — no clean home on an Assistant turn (§2.3).
8234            _ => {}
8235        }
8236    }
8237
8238    let before = out.len();
8239    push_assistant(out, text, calls);
8240    // A native OpenCode assistant record is transcript state even when it
8241    // has no parts. Real stores contain these after an interrupted/empty
8242    // model turn; dropping the record here loses its id, timestamp, model,
8243    // token/cost metadata, and shifts the conversation on every export.
8244    // Keep one empty canonical assistant message so all target writers can
8245    // preserve the turn. This also covers reasoning-only records (whose
8246    // reasoning payload is attached as metadata just below).
8247    if out.len() == before {
8248        let mut empty = ChatMessage {
8249            role: Role::Assistant,
8250            content: None,
8251            content_parts: None,
8252            tool_calls: None,
8253            tool_call_id: None,
8254            name: None,
8255            metadata: Default::default(),
8256        };
8257        if !reasoning_seen {
8258            empty
8259                .metadata
8260                .insert("empty_assistant_record".to_string(), "true".to_string());
8261        }
8262        out.push(empty);
8263    }
8264    if out.len() > before {
8265        let msg = out.last_mut().expect("just pushed");
8266        if reasoning_seen {
8267            msg.metadata.insert("thinking".to_string(), thinking);
8268        }
8269        if let Some(sig) = thinking_sig {
8270            msg.metadata.insert("thinking_signature".to_string(), sig);
8271        }
8272        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8273            msg.metadata
8274                .insert("oc_message_id".to_string(), id.to_string());
8275        }
8276        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8277            msg.metadata.insert("agent".to_string(), agent.to_string());
8278            if meta.agent_id.is_none() {
8279                meta.agent_id = Some(agent.to_string());
8280            }
8281        }
8282        let provider = msg_value.get("providerID").and_then(Value::as_str);
8283        let model_id = msg_value.get("modelID").and_then(Value::as_str);
8284        if let (Some(p), Some(i)) = (provider, model_id) {
8285            let full = format!("{p}/{i}");
8286            msg.metadata.insert("model".to_string(), full.clone());
8287            if meta.model.is_none() {
8288                meta.model = Some(full);
8289            }
8290        }
8291        if let Some(cwd) = msg_value
8292            .get("path")
8293            .and_then(|p| p.get("cwd"))
8294            .and_then(Value::as_str)
8295        {
8296            if meta.cwd.is_none() {
8297                meta.cwd = Some(PathBuf::from(cwd));
8298            }
8299        }
8300        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8301            msg.metadata
8302                .insert("is_summary".to_string(), "true".to_string());
8303        }
8304        for (key, field) in [
8305            ("finish", "finish"),
8306            ("variant", "variant"),
8307            ("mode", "mode"),
8308        ] {
8309            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8310                msg.metadata.insert(key.to_string(), s.to_string());
8311            }
8312        }
8313        for (key, field) in [
8314            ("cost", "cost"),
8315            ("tokens", "tokens"),
8316            ("error", "error"),
8317            ("structured", "structured"),
8318        ] {
8319            if let Some(v) = msg_value.get(field) {
8320                if !v.is_null() {
8321                    msg.metadata.insert(key.to_string(), v.to_string());
8322                }
8323            }
8324        }
8325        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8326        // the spawned child session id — keyed by callID so multiple `task`
8327        // calls in one message never collide.
8328        // `resolve_opencode_parent_tool_use_ids` reads these back once a
8329        // whole session set is loaded.
8330        for p in parts {
8331            if p.get("type").and_then(Value::as_str) == Some("tool")
8332                && p.get("tool").and_then(Value::as_str) == Some("task")
8333            {
8334                if let (Some(call_id), Some(child)) = (
8335                    p.get("callID").and_then(Value::as_str),
8336                    p.get("metadata")
8337                        .and_then(|m| m.get("sessionId"))
8338                        .and_then(Value::as_str),
8339                ) {
8340                    msg.metadata.insert(
8341                        format!("oc_task_child_session_id__{call_id}"),
8342                        child.to_string(),
8343                    );
8344                }
8345            }
8346        }
8347        set_opencode_msg_timestamp(msg, msg_value);
8348        restore_grok_message_extension(msg_value, msg);
8349    }
8350
8351    // Second pass: the paired Tool-role message for each completed/error
8352    // tool part, split by callID (§2.1 — "the SAME part carries call and
8353    // result").
8354    for (call_id, tool_name, part) in tool_results {
8355        let status = part
8356            .get("state")
8357            .and_then(|s| s.get("status"))
8358            .and_then(Value::as_str);
8359        let compacted_at = part
8360            .get("state")
8361            .and_then(|s| s.get("time"))
8362            .and_then(|t| t.get("compacted"))
8363            .and_then(Value::as_i64);
8364        let real_output = part
8365            .get("state")
8366            .and_then(|s| s.get("output"))
8367            .and_then(Value::as_str)
8368            .unwrap_or("")
8369            .to_string();
8370        let (content, is_error) = match status {
8371            Some("completed") => {
8372                if compacted_at.is_some() {
8373                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8374                } else {
8375                    (real_output.clone(), false)
8376                }
8377            }
8378            Some("error") => {
8379                let err = part
8380                    .get("state")
8381                    .and_then(|s| s.get("error"))
8382                    .and_then(Value::as_str)
8383                    .unwrap_or("")
8384                    .to_string();
8385                (err, true)
8386            }
8387            _ => (String::new(), false),
8388        };
8389        let mut tmsg = ChatMessage {
8390            role: Role::Tool,
8391            content: Some(content),
8392            content_parts: None,
8393            tool_calls: None,
8394            tool_call_id: Some(call_id),
8395            name: Some(tool_name),
8396            metadata: Default::default(),
8397        };
8398        if let Some(original_position) = part
8399            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8400            .and_then(Value::as_u64)
8401        {
8402            tmsg.metadata.insert(
8403                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8404                original_position.to_string(),
8405            );
8406        }
8407        if is_error {
8408            crate::mark_tool_error(&mut tmsg);
8409        }
8410        restore_tool_outcome_extension(&part, &mut tmsg);
8411        if let Some(ts) = compacted_at {
8412            // S1: the real output is preserved — reversible, never erased.
8413            tmsg.metadata
8414                .insert("oc_tool_output_compacted".to_string(), real_output);
8415            tmsg.metadata
8416                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8417        }
8418        if status == Some("completed") {
8419            if let Some(atts) = part
8420                .get("state")
8421                .and_then(|s| s.get("attachments"))
8422                .and_then(Value::as_array)
8423            {
8424                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8425                if !images.is_empty() {
8426                    // D-mix consistency fix (Fable-recommended, same
8427                    // pattern as `push_claude_user`'s tool_result arm above):
8428                    // a completed opencode tool part with BOTH `state.output`
8429                    // text and `state.attachments` images is the same
8430                    // non-self-contained hybrid shape — `content_parts` here
8431                    // used to hold images only, so opencode -> pi silently
8432                    // dropped the output text (`pi_content_value` reads
8433                    // `content_parts` exclusively for `Role::Tool`). Prepend
8434                    // the text as part 0 so `content_parts` is
8435                    // self-contained; `tmsg.content` keeps the text too,
8436                    // unchanged, for writers that read it from there and
8437                    // only scan `content_parts` for `image_url` entries.
8438                    let mut parts = Vec::new();
8439                    if let Some(t) = &tmsg.content {
8440                        if !t.is_empty() {
8441                            parts.push(serde_json::json!({"type": "text", "text": t}));
8442                        }
8443                    }
8444                    parts.extend(images);
8445                    tmsg.content_parts = Some(parts);
8446                }
8447            }
8448        }
8449        if let Some(id) = part.get("id").and_then(Value::as_str) {
8450            tmsg.metadata
8451                .insert("oc_part_id".to_string(), id.to_string());
8452        }
8453        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8454        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8455        // cite `state.time.compacted`, but the SAME object also carries
8456        // `start`/`end` on every completed/error call) is this Tool
8457        // message's real source timestamp; prefer `end` (completion, closer
8458        // to when the RESULT — this message's content — was produced) and
8459        // fall back to `start` when only that is present.
8460        let tool_ts = part
8461            .get("state")
8462            .and_then(|s| s.get("time"))
8463            .and_then(|t| t.get("end").or_else(|| t.get("start")))
8464            .and_then(Value::as_i64);
8465        if let Some(ms) = tool_ts {
8466            tmsg.metadata
8467                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8468        }
8469        // OpenCode folds a canonical tool result into the assistant's tool
8470        // part. Restore the portable envelope from that part after native
8471        // fields have been captured so A -> OpenCode -> A retains fields
8472        // OpenCode does not model independently (for example Goose's
8473        // message-level metadata and an intentionally absent tool name).
8474        restore_grok_message_extension(&part, &mut tmsg);
8475        out.push(tmsg);
8476    }
8477}
8478
8479// ---- shared helpers -------------------------------------------------------
8480
8481fn push_text(buf: &mut String, v: Option<&Value>) {
8482    if let Some(Value::String(s)) = v {
8483        if !buf.is_empty() {
8484            buf.push('\n');
8485        }
8486        buf.push_str(s);
8487    }
8488}
8489
8490/// Extract a Claude `tool_result` block's content, preserving non-text items
8491/// instead of silently dropping them:
8492///
8493/// - text blocks are concatenated;
8494/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8495///   PNG / screenshot tool output" shape): `image` blocks are captured into
8496///   the returned `content_parts`-shaped `Vec<Value>` via
8497///   [`claude_image_block_to_part`] — the SAME base64/url conversion the
8498///   top-level `image` content-block path (`push_claude_user`) already uses
8499///   — instead of being flattened to the bare `[image]` marker text that used
8500///   to make the data unrecoverable from every writer. An unconvertible
8501///   source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8502///   etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8503///   vanishing, exactly like the top-level path;
8504/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8505///
8506/// and if the block yields no text/images at all, fall back to the record's
8507/// `toolUseResult` field (string used directly, structured value serialized),
8508/// which is where Claude Code stores the actual result in many cases.
8509///
8510/// Returns `(text, images)`; callers that only need the old text-only
8511/// behavior can ignore the second element — every caller MUST fold non-empty
8512/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8513/// function has no `ChatMessage` to attach to).
8514fn extract_tool_result_content(
8515    content: Option<&Value>,
8516    tool_use_result: Option<&Value>,
8517) -> (String, Vec<Value>) {
8518    let mut parts: Vec<String> = Vec::new();
8519    let mut images: Vec<Value> = Vec::new();
8520    match content {
8521        Some(Value::String(s)) => {
8522            if !s.is_empty() {
8523                parts.push(s.clone());
8524            }
8525        }
8526        Some(Value::Array(items)) => {
8527            for item in items {
8528                match item.get("type").and_then(Value::as_str) {
8529                    Some("text") => {
8530                        if let Some(t) = item.get("text").and_then(Value::as_str) {
8531                            parts.push(t.to_string());
8532                        }
8533                    }
8534                    Some("image") => match claude_image_block_to_part(item) {
8535                        Some(part) => images.push(part),
8536                        None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8537                    },
8538                    Some("tool_reference") => {
8539                        let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8540                        parts.push(format!("[tool_reference: {name}]"));
8541                    }
8542                    _ => {
8543                        if let Some(s) = item.as_str() {
8544                            parts.push(s.to_string());
8545                        }
8546                    }
8547                }
8548            }
8549        }
8550        Some(other) => parts.push(other.to_string()),
8551        None => {}
8552    }
8553
8554    let joined = parts.join("\n");
8555    if !joined.trim().is_empty() || !images.is_empty() {
8556        return (joined, images);
8557    }
8558    // Empty tool_result content — recover from toolUseResult.
8559    match tool_use_result {
8560        Some(Value::String(s)) => (s.clone(), images),
8561        Some(v) => (v.to_string(), images),
8562        None => (joined, images),
8563    }
8564}
8565
8566/// Pull readable text out of a content value that may be a plain string or an
8567/// array of `{ "text": "..." }`-bearing blocks (any block type).
8568fn extract_text_content(v: Option<&Value>) -> String {
8569    match v {
8570        Some(Value::String(s)) => s.clone(),
8571        Some(Value::Array(items)) => {
8572            let mut parts = Vec::new();
8573            for item in items {
8574                if let Some(t) = item.get("text").and_then(Value::as_str) {
8575                    parts.push(t.to_string());
8576                } else if let Some(s) = item.as_str() {
8577                    parts.push(s.to_string());
8578                }
8579            }
8580            parts.join("\n")
8581        }
8582        Some(other) => other.to_string(),
8583        None => String::new(),
8584    }
8585}
8586
8587/// Extract Codex `input_image` content blocks from a `message` response_item's
8588/// `content` value into `content_parts` `image_url` entries — the inverse of
8589/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8590/// block whose `image_url` is a non-empty string is recognized; anything else
8591/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8592/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8593/// the pi/opencode/Claude loaders' image-shape discipline.
8594fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8595    let Some(Value::Array(items)) = content else {
8596        return Vec::new();
8597    };
8598    items
8599        .iter()
8600        .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8601        .filter_map(|item| {
8602            let url = item.get("image_url").and_then(Value::as_str)?;
8603            if url.is_empty() {
8604                return None;
8605            }
8606            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8607        })
8608        .collect()
8609}
8610
8611fn value_to_arg_string(v: &Value) -> String {
8612    match v {
8613        Value::String(s) => s.clone(),
8614        other => other.to_string(),
8615    }
8616}
8617
8618fn push_gemini_user_parts(
8619    messages: &mut Vec<ChatMessage>,
8620    content_parts: Vec<Value>,
8621    timestamp: Option<&str>,
8622    source: &Value,
8623) {
8624    if content_parts.is_empty() {
8625        return;
8626    }
8627    let mut message = ChatMessage {
8628        role: Role::User,
8629        content: None,
8630        content_parts: Some(content_parts),
8631        tool_calls: None,
8632        tool_call_id: None,
8633        name: None,
8634        metadata: Default::default(),
8635    };
8636    if let Some(timestamp) = timestamp {
8637        message
8638            .metadata
8639            .insert("timestamp".into(), timestamp.into());
8640    }
8641    restore_gemini_message_extension(source, &mut message);
8642    messages.push(message);
8643}
8644
8645fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8646    ToolCall {
8647        id: id.to_string(),
8648        kind: "function".to_string(),
8649        function: FunctionCall {
8650            name: name.to_string(),
8651            arguments,
8652        },
8653    }
8654}
8655
8656fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8657    ChatMessage {
8658        role: Role::Tool,
8659        content: Some(content),
8660        content_parts: None,
8661        tool_calls: None,
8662        tool_call_id: Some(tool_call_id.to_string()),
8663        name: None,
8664        metadata: Default::default(),
8665    }
8666}
8667
8668/// Emit a single assistant message combining accumulated text and tool calls.
8669/// A turn with neither (e.g. thinking-only) produces nothing.
8670fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8671    let has_text = !text.trim().is_empty();
8672    if !has_text && calls.is_empty() {
8673        return;
8674    }
8675    out.push(ChatMessage {
8676        role: Role::Assistant,
8677        content: has_text.then_some(text),
8678        content_parts: None,
8679        tool_calls: (!calls.is_empty()).then_some(calls),
8680        tool_call_id: None,
8681        name: None,
8682        metadata: Default::default(),
8683    });
8684}
8685
8686// ---- writers --------------------------------------------------------------
8687
8688/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8689/// fallback (`docs/interop` build brief): every writer now emits a message's
8690/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8691/// field every loader populates) when one is present. `SYNTH_TS` fires only
8692/// for a message with no source timestamp at all — a turn synthesized/
8693/// appended after import (the live agent loop, a splice's appended tail,
8694/// ...), which was never loaded from a real per-message timestamp to begin
8695/// with. Both tools tolerate identical timestamps; callers that need real
8696/// ones for a synthesized turn can post-process.
8697const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8698
8699/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8700/// `time.created`/`time.updated` fields.
8701const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8702
8703/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8704/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8705/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8706/// parse, not just a presence check) so an absent, empty, or malformed
8707/// source value all degrade to the same documented fallback rather than
8708/// propagating garbage verbatim. Used by every writer that emits an
8709/// ISO-8601 timestamp field
8710/// (Claude Code, Codex, pi's entry-level `timestamp`).
8711fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8712    match msg.metadata.get("timestamp") {
8713        Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8714        _ => SYNTH_TS,
8715    }
8716}
8717
8718/// OpenCode reloads an export document by sorting messages on
8719/// `time.created`, so a timestamp-less appended continuation cannot reuse
8720/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8721/// newer. Advance a deterministic cursor for synthesized clocks while still
8722/// preserving every real source timestamp verbatim.
8723fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8724    if let Some(real) = msg
8725        .metadata
8726        .get("timestamp")
8727        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8728    {
8729        // A NativeTurn timestamp is durable provenance minted by supercode,
8730        // not an OpenCode source clock that must be replayed verbatim.
8731        // Multiple turns may be recorded in the same millisecond, while
8732        // OpenCode sorts solely by `time.created`; allocate such turns after
8733        // the existing cursor so their persisted order cannot collapse. This
8734        // also preserves the fail-closed i64::MAX exhaustion behavior.
8735        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8736            *cursor = cursor.checked_add(1).ok_or_else(|| {
8737                crate::Error::Other(
8738                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8739                        .to_string(),
8740                )
8741            })?;
8742            return Ok(*cursor);
8743        }
8744        *cursor = (*cursor).max(real);
8745        return Ok(real);
8746    }
8747    let next = cursor.checked_add(1).ok_or_else(|| {
8748        crate::Error::Other(
8749            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8750        )
8751    })?;
8752    *cursor = next.max(SYNTH_TS_MS);
8753    Ok(*cursor)
8754}
8755
8756/// Largest integer nested under any OpenCode `time` object. Imported
8757/// prefixes carry more clocks than `message.time.created` (assistant
8758/// completion, tool start/end, session updated); a synthesized continuation
8759/// must follow all of them, not merely sort after message creation times.
8760fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8761    fn max_number(value: &Value) -> Option<i64> {
8762        match value {
8763            Value::Number(n) => n.as_i64(),
8764            Value::Array(values) => values.iter().filter_map(max_number).max(),
8765            Value::Object(fields) => fields.values().filter_map(max_number).max(),
8766            _ => None,
8767        }
8768    }
8769
8770    match value {
8771        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8772        Value::Object(fields) => fields
8773            .iter()
8774            .filter_map(|(key, value)| {
8775                if key == "time" {
8776                    max_number(value)
8777                } else {
8778                    opencode_max_timestamp(value)
8779                }
8780            })
8781            .max(),
8782        _ => None,
8783    }
8784}
8785
8786/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8787/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8788/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8789/// reads. The two carry genuinely different values in real pi corpora (a
8790/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8791/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8792/// nested `message.timestamp` field, so a pi -> pi native round-trip
8793/// preserves the source message-level clock value-exact instead of deriving
8794/// it from the (distinct) entry-level timestamp. Falls back to
8795/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8796/// reading (non-pi-sourced, or a synthesized/appended turn).
8797fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8798    msg.metadata
8799        .get("pi_msg_timestamp")
8800        .and_then(|s| s.parse::<i64>().ok())
8801        .unwrap_or(SYNTH_TS_MS)
8802}
8803
8804/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8805fn synth_uuid(n: usize) -> String {
8806    format!("00000000-0000-4000-8000-{n:012x}")
8807}
8808
8809/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8810/// class N2 closed for the Codex spliced path's group ids, see
8811/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8812/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8813/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8814/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8815/// ahead of the tail this counter mints. Without this, re-splicing a
8816/// previously-exported-then-reimported session (export -> reimport -> append
8817/// -> export again) restarts `counter` at 1 with no memory of the prior
8818/// export's tail uuids now sitting in the prefix, so the second tail
8819/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8820/// — a uuid collision across prefix and tail that can mis-link any
8821/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8822/// climbing monotonically even across skips. `used_ids` is also updated for
8823/// each minted or metadata-backed identity, so collisions are prevented both
8824/// against the replayed prefix and within the appended tail.
8825fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8826    loop {
8827        let candidate = synth_uuid(*counter);
8828        *counter += 1;
8829        if used_ids.insert(candidate.clone()) {
8830            return candidate;
8831        }
8832    }
8833}
8834
8835/// Reuse a message's durable native/source UUID when available, falling back
8836/// to the deterministic synthesized sequence only for hand-built or legacy
8837/// messages that never carried identity metadata.
8838fn claude_message_uuid(
8839    msg: &ChatMessage,
8840    counter: &mut usize,
8841    used_ids: &mut HashSet<String>,
8842) -> String {
8843    for key in ["claude_uuid", "supercode_native_uuid"] {
8844        if let Some(candidate) = msg.metadata.get(key) {
8845            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8846                return candidate.clone();
8847            }
8848        }
8849    }
8850    next_claude_uuid(counter, used_ids)
8851}
8852
8853/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8854/// `raw_prefix` — the verbatim RAW lines
8855/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8856/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8857/// the GROUND TRUTH of what physically lands in the exported `out` string
8858/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8859/// the Codex side): each line is parsed as a Claude Code JSONL record and
8860/// its own top-level `uuid` field is read back out of the bytes directly, no
8861/// re-derivation from `self.messages` needed. A line that fails to parse, or
8862/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8863/// record), contributes nothing.
8864fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8865    let mut ids = HashSet::new();
8866    for line in raw_prefix {
8867        if let Ok(v) = serde_json::from_str::<Value>(line) {
8868            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8869                ids.insert(uuid.to_string());
8870            }
8871        }
8872    }
8873    ids
8874}
8875
8876fn push_jsonl(out: &mut String, value: &Value) {
8877    out.push_str(&value.to_string());
8878    out.push('\n');
8879}
8880
8881/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8882/// `new_id` when the line parses as a JSON object carrying that key — used
8883/// by A12's Claude Code splice, where the session id lives at the top level
8884/// of (almost) every record under `key = "sessionId"`. A line that fails to
8885/// parse, or parses but lacks `key`, is copied through byte-for-byte
8886/// (nothing to patch, so nothing is reserialized).
8887fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8888    if let Some(new_id) = new_id {
8889        if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8890            if v.get(key).is_some() {
8891                v[key] = Value::String(new_id.to_string());
8892                out.push_str(&v.to_string());
8893                out.push('\n');
8894                return;
8895            }
8896        }
8897    }
8898    out.push_str(line);
8899    out.push('\n');
8900}
8901
8902impl Session {
8903    fn cwd_string(&self) -> String {
8904        self.meta
8905            .cwd
8906            .as_ref()
8907            .map(|p| p.to_string_lossy().into_owned())
8908            .unwrap_or_else(|| ".".to_string())
8909    }
8910
8911    /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
8912    /// leading `raw` lines / `messages` came from the imported log, as
8913    /// opposed to being appended after import.
8914    ///
8915    /// `imported_message_count` (see its doc comment) pins the message-side
8916    /// boundary directly. The raw-side boundary isn't separately tracked —
8917    /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
8918    /// `raw` line per appended message, so the two lists grow by the same
8919    /// `appended_count` from the same starting point, and
8920    /// `raw.len() - appended_count` recovers it without a second counter.
8921    fn spliced_prefix_lens(&self) -> (usize, usize) {
8922        let message_prefix_len = self
8923            .imported_message_count
8924            .unwrap_or(self.messages.len())
8925            .min(self.messages.len());
8926        let appended_count = self.messages.len() - message_prefix_len;
8927        let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
8928        (raw_prefix_len, message_prefix_len)
8929    }
8930
8931    /// Synthesize a Claude Code transcript.
8932    ///
8933    /// Claude Code transcripts have no slot for the *session-level system
8934    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
8935    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
8936    /// `ChatMessage`s (Claude's own `type: "system"` records with a
8937    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
8938    /// `away_summary` — see `push_claude_system`, the exact inverse of what
8939    /// this writer now does) DO have a first-class slot: the real `type:
8940    /// "system"` record itself. This function used to unconditionally drop
8941    /// every `System` message, silently losing e.g. a real
8942    /// `<local-command-stdout>` record on any format -> Claude Code hop
8943    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
8944    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
8945    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
8946    /// now re-materializes it instead.
8947    fn to_claude_code_jsonl(&self) -> String {
8948        let session_id = self
8949            .meta
8950            .session_id
8951            .clone()
8952            .unwrap_or_else(|| synth_uuid(0));
8953        let cwd = self.cwd_string();
8954        let mut out = String::new();
8955        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
8956        // re-emitted byte-for-byte, ahead of the conversation it applies to —
8957        // this is what makes the record survive the SEMANTIC Claude Code
8958        // writer (the raw-passthrough diagonal in `crates/cli` already
8959        // preserves it by construction; this covers the library `to_jsonl`
8960        // path too, e.g. a `--session-id` override that forces the semantic
8961        // writer).
8962        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
8963            out.push_str(raw);
8964            out.push('\n');
8965        }
8966        // Full synthesis: `out` at this point has no raw prefix ahead of it
8967        // (unlike the A12 splice below), so there are no uuids yet in play
8968        // to seed against — see `next_claude_uuid`'s doc comment.
8969        self.write_claude_code_records(
8970            &mut out,
8971            &self.messages,
8972            &session_id,
8973            &cwd,
8974            None,
8975            1,
8976            &HashSet::new(),
8977        );
8978        if let Some(extension) = codex_provenance_envelope(&self.meta) {
8979            if out.is_empty() {
8980                push_jsonl(
8981                    &mut out,
8982                    &serde_json::json!({
8983                        "type": "file-history-snapshot",
8984                        "messageId": synth_uuid(1),
8985                        "snapshot": {},
8986                        "sessionId": session_id,
8987                        "cwd": cwd,
8988                        "timestamp": SYNTH_TS,
8989                    }),
8990                );
8991            }
8992            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8993        }
8994        out
8995    }
8996
8997    /// Synthesize Claude Code records for `messages` (a full session or an
8998    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
8999    /// the latter), starting the `parentUuid` chain at `parent` and the
9000    /// `synth_uuid` counter at `counter`. Factored out of
9001    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
9002    ///
9003    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
9004    /// every uuid that will ALREADY be present in `out` before this call
9005    /// ever runs — see that function's doc comment for why the A12 splice
9006    /// path needs this and full synthesis doesn't.
9007    // R1: this was already at clippy's `too_many_arguments` threshold (7,
9008    // including `&self`) before the fix; the added `seed_used_ids` param
9009    // pushes it to 8. Every argument here is independently meaningful (two
9010    // record-shape inputs, two id/parent-chain threading values, and now
9011    // the collision seed) — bundling them into a params struct is a larger
9012    // refactor of this already-widely-called private helper than the R1 fix
9013    // warrants, so this is allowed rather than restructured.
9014    #[allow(clippy::too_many_arguments)]
9015    fn write_claude_code_records(
9016        &self,
9017        out: &mut String,
9018        messages: &[ChatMessage],
9019        session_id: &str,
9020        cwd: &str,
9021        mut parent: Option<String>,
9022        mut counter: usize,
9023        seed_used_ids: &HashSet<String>,
9024    ) {
9025        let mut used_ids = seed_used_ids.clone();
9026        for msg in messages {
9027            if is_replay_excluded(msg) {
9028                continue;
9029            }
9030            let blocks: Vec<Value> = match msg.role {
9031                // PARITY-6 dev/02: re-materialize a content-bearing System
9032                // `ChatMessage` as a real Claude Code `type: "system"`
9033                // record — the exact inverse of `push_claude_system`, which
9034                // is what produced it in the first place for a message
9035                // loaded FROM a real Claude Code transcript. `subtype`
9036                // prefers the original `systemSubtype` metadata
9037                // (`push_claude_system`'s `.with_meta`, round-tripped
9038                // through the Codex hop via `write_codex_records`'s
9039                // `claude_system_subtype` metadata channel and restored by
9040                // `push_codex_item`); when that channel didn't carry it
9041                // (e.g. a genuinely native, non-Claude-origin developer
9042                // message), fall back to `local_command` — the observed
9043                // common case, and still one of `push_claude_system`'s own
9044                // `keep` subtypes, so the record survives a *subsequent*
9045                // reload rather than being silently re-dropped. This never
9046                // fabricates content: the real text is always carried
9047                // verbatim, only the subtype label is a best-effort guess
9048                // when the true one wasn't recoverable.
9049                Role::System => {
9050                    let content = msg.content.clone().unwrap_or_default();
9051                    if content.trim().is_empty() {
9052                        continue;
9053                    }
9054                    let subtype = msg
9055                        .metadata
9056                        .get("systemSubtype")
9057                        .cloned()
9058                        .unwrap_or_else(|| "local_command".to_string());
9059                    // R1/B3 union: this mint must ALSO route through
9060                    // `next_claude_uuid` + `seed_used_ids` like the other
9061                    // three arms below — otherwise this System arm (added by
9062                    // B3 after R1 landed) mints a raw `synth_uuid` that can
9063                    // collide with a uuid already sitting in the A12 splice's
9064                    // raw prefix (see `next_claude_uuid`'s doc comment).
9065                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9066                    let mut line = serde_json::json!({
9067                        "parentUuid": parent,
9068                        "type": "system",
9069                        "subtype": subtype,
9070                        "content": content,
9071                        "uuid": uuid,
9072                        "sessionId": session_id,
9073                        "cwd": cwd,
9074                        "timestamp": msg_timestamp_or_synth(msg),
9075                    });
9076                    set_grok_message_extension(&mut line, self.meta.source, msg);
9077                    push_jsonl(out, &line);
9078                    parent = Some(uuid);
9079                    continue;
9080                }
9081                Role::User => {
9082                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9083                    let mut line = serde_json::json!({
9084                        "parentUuid": parent,
9085                        "type": "user",
9086                        "message": {
9087                            "role": "user",
9088                            "content": claude_user_content_value(msg),
9089                        },
9090                        "uuid": uuid,
9091                        "sessionId": session_id,
9092                        "cwd": cwd,
9093                        "timestamp": msg_timestamp_or_synth(msg),
9094                    });
9095                    set_grok_message_extension(&mut line, self.meta.source, msg);
9096                    push_jsonl(out, &line);
9097                    parent = Some(uuid);
9098                    continue;
9099                }
9100                Role::Tool => {
9101                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9102                    let mut line = serde_json::json!({
9103                        "parentUuid": parent,
9104                        "type": "user",
9105                        "message": {
9106                            "role": "user",
9107                            "content": [{
9108                                "type": "tool_result",
9109                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9110                                "content": claude_tool_result_content_value(msg),
9111                            }],
9112                        },
9113                        "uuid": uuid,
9114                        "sessionId": session_id,
9115                        "cwd": cwd,
9116                        "timestamp": msg_timestamp_or_synth(msg),
9117                    });
9118                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9119                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9120                    }
9121                    set_grok_message_extension(&mut line, self.meta.source, msg);
9122                    push_jsonl(out, &line);
9123                    parent = Some(uuid);
9124                    continue;
9125                }
9126                Role::Assistant => {
9127                    let mut blocks = Vec::new();
9128                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
9129                    // dev/01): thinking/redacted_thinking must be re-emitted
9130                    // BEFORE text/tool_use, unconditionally whenever
9131                    // retained metadata is present — not only when `blocks`
9132                    // is otherwise empty. The previous `if blocks.is_empty()`
9133                    // gate (now below, applied unconditionally instead)
9134                    // meant a turn that thinks AND THEN answers/calls a tool
9135                    // in the SAME turn — pi's own default emission shape,
9136                    // and the overwhelmingly common real-world case for any
9137                    // reasoning model, not the rare reasoning-only edge case
9138                    // this gate's comment described — silently dropped its
9139                    // entire `thinking` block on Pi -> Claude Code export. A
9140                    // genuine multi-turn pi session driven through pi's own
9141                    // real Agent loop (faux provider, see
9142                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
9143                    // thinking+text turns lost the thinking block entirely.
9144                    // D8: prefer the exact per-block list when present —
9145                    // every `thinking`/`redacted_thinking` block re-emitted
9146                    // SEPARATELY with its own signature/data, exactly as
9147                    // captured (`push_claude_assistant`), instead of the
9148                    // legacy singular fields' lossy collapse (which drops
9149                    // every signature but the last one's on a multi-block
9150                    // message). Falls back to the legacy fields only for a
9151                    // `Session` that never populated `thinking_blocks` (e.g.
9152                    // hand-constructed in another loader/test, or loaded
9153                    // from a non-Claude-Code source like Pi).
9154                    match msg
9155                        .metadata
9156                        .get("thinking_blocks")
9157                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9158                        .and_then(|v| v.as_array().cloned())
9159                    {
9160                        Some(saved_blocks) => blocks.extend(saved_blocks),
9161                        None => {
9162                            if let Some(t) = msg.metadata.get("thinking") {
9163                                let mut block =
9164                                    serde_json::json!({"type": "thinking", "thinking": t});
9165                                if let Some(sig) = msg.metadata.get("thinking_signature") {
9166                                    block["signature"] = Value::String(sig.clone());
9167                                }
9168                                blocks.push(block);
9169                            }
9170                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
9171                                blocks.push(
9172                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
9173                                );
9174                            }
9175                        }
9176                    }
9177                    if let Some(t) = &msg.content {
9178                        if !t.is_empty() {
9179                            blocks.push(serde_json::json!({"type": "text", "text": t}));
9180                        }
9181                    }
9182                    // PARITY-11: an assistant-emitted image (`content_parts`,
9183                    // e.g. a generated image — `push_claude_assistant`'s
9184                    // load-side counterpart) has no slot in `msg.content`;
9185                    // without this, `blocks` stayed empty for an image-only
9186                    // turn and the whole message vanished on Claude Code
9187                    // semantic export, same failure mode the IX-6 Codex
9188                    // writer fix already closed on that side.
9189                    if let Some(parts) = &msg.content_parts {
9190                        for p in parts {
9191                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
9192                                if let Some(url) = p
9193                                    .get("image_url")
9194                                    .and_then(|u| u.get("url"))
9195                                    .and_then(Value::as_str)
9196                                {
9197                                    blocks.push(match parse_data_uri(url) {
9198                                        Some((mime, data)) => serde_json::json!({
9199                                            "type": "image",
9200                                            "source": {"type": "base64", "media_type": mime, "data": data},
9201                                        }),
9202                                        None => serde_json::json!({
9203                                            "type": "image",
9204                                            "source": {"type": "url", "url": url},
9205                                        }),
9206                                    });
9207                                }
9208                            }
9209                        }
9210                    }
9211                    for tc in msg.tool_calls() {
9212                        let input = tc
9213                            .function
9214                            .parsed_arguments()
9215                            .unwrap_or_else(|_| Value::Object(Default::default()));
9216                        blocks.push(serde_json::json!({
9217                            "type": "tool_use",
9218                            "id": tc.id,
9219                            "name": tc.function.name,
9220                            "input": input,
9221                        }));
9222                    }
9223                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9224                    // (no text, no tool_use, no image) still doesn't vanish
9225                    // — the thinking/redacted_thinking prepend above already
9226                    // ran unconditionally, so `blocks` is non-empty here
9227                    // whenever any of those were present.
9228                    blocks
9229                }
9230            };
9231
9232            // An empty assistant content array is a valid native interrupted
9233            // turn and must remain a record. Every non-assistant arm above
9234            // already `continue`s after writing its own shape, so an empty
9235            // `blocks` value here belongs specifically to that assistant.
9236            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9237            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9238            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9239                message["model"] = Value::String(model.clone());
9240            }
9241            let mut line = serde_json::json!({
9242                "parentUuid": parent,
9243                "type": "assistant",
9244                "message": message,
9245                "uuid": uuid,
9246                "sessionId": session_id,
9247                "cwd": cwd,
9248                "timestamp": msg_timestamp_or_synth(msg),
9249            });
9250            set_grok_message_extension(&mut line, self.meta.source, msg);
9251            push_jsonl(out, &line);
9252            parent = Some(uuid);
9253        }
9254    }
9255
9256    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9257    /// (patching `sessionId` on each line when `session_id` is `Some`), then
9258    /// synthesize records only for the appended tail, via
9259    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9260    /// last original `uuid` found anywhere in the raw prefix (not just its
9261    /// final line: a trailing loader-skipped record, e.g.
9262    /// `file-history-snapshot`, may carry no `uuid` of its own).
9263    fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9264        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9265        let sid = session_id
9266            .map(str::to_string)
9267            .or_else(|| self.meta.session_id.clone())
9268            .unwrap_or_else(|| synth_uuid(0));
9269        let cwd = self.cwd_string();
9270
9271        let mut out = String::new();
9272        let mut parent: Option<String> = None;
9273        for line in &self.raw[..raw_prefix_len] {
9274            push_spliced_line(&mut out, line, session_id, "sessionId");
9275            if let Ok(v) = serde_json::from_str::<Value>(line) {
9276                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9277                    parent = Some(uuid.to_string());
9278                }
9279            }
9280        }
9281
9282        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9283        // the tail's collision guard with every uuid the just-replayed RAW
9284        // prefix already carries, so `write_claude_code_records` never
9285        // fabricates a `synth_uuid` for the appended tail that collides with
9286        // one already sitting in the prefix (see `next_claude_uuid`'s and
9287        // `collect_claude_uuids_from_raw`'s doc comments).
9288        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9289        self.write_claude_code_records(
9290            &mut out,
9291            &self.messages[message_prefix_len..],
9292            &sid,
9293            &cwd,
9294            parent,
9295            1,
9296            &seed_used_ids,
9297        );
9298        out
9299    }
9300
9301    /// Synthesize a Codex rollout.
9302    fn to_codex_jsonl(&self) -> String {
9303        let mut out = String::new();
9304
9305        if self.meta.codex_headers.is_empty() {
9306            self.write_synthesized_codex_header(&mut out);
9307        } else {
9308            // Replay the exact header records the original tool wrote — Codex's
9309            // reader validates the header shape strictly — overriding only the
9310            // session id when the caller changed it.
9311            for header in &self.meta.codex_headers {
9312                let mut header = header.clone();
9313                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9314                    if let Some(id) = &self.meta.session_id {
9315                        if let Some(payload) = header.get_mut("payload") {
9316                            payload["id"] = Value::String(id.clone());
9317                        }
9318                    }
9319                }
9320                push_jsonl(&mut out, &header);
9321            }
9322        }
9323
9324        // Full synthesis: `out` at this point is only the header, so there
9325        // are no group ids yet in play to seed against (see
9326        // `write_codex_records`'s doc comment).
9327        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9328        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9329            inject_codex_provenance(&mut out, extension);
9330        }
9331        out
9332    }
9333
9334    /// Synthesize Codex `response_item` records for `messages` (a full
9335    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9336    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9337    /// the record shape is defined once; `tool_search_call_ids` pairing is
9338    /// scoped to this call's `messages`, matching the header-replay
9339    /// contract that only appended records need synthesizing.
9340    ///
9341    /// `seed_used_ids` primes the N2 collision guard below with every group
9342    /// id that will ALREADY be present in `out` before this call ever runs —
9343    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9344    /// header) passes an empty set, since every group id in that case is
9345    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9346    /// splice) passes the ids already used by the verbatim RAW prefix it
9347    /// replayed into `out` just before calling this for the appended tail —
9348    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9349    /// start blind to the prefix and can fabricate/reuse a group id that
9350    /// COLLIDES with one still "open" at the end of the prefix, letting
9351    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9352    /// an unrelated appended message into a historical one — the same
9353    /// bug-class N2 closed for full synthesis, reopened here because the
9354    /// spliced tail's tracking set used to always start empty regardless of
9355    /// what the replayed prefix already contained.
9356    fn write_codex_records(
9357        &self,
9358        out: &mut String,
9359        messages: &[ChatMessage],
9360        seed_used_ids: &std::collections::HashSet<String>,
9361    ) {
9362        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9363        // the matching tool result below can be emitted as the paired
9364        // `tool_search_output` record rather than a generic
9365        // `function_call_output` — the exact inverse of the importer's
9366        // `tool_search_call`/`tool_search_output` normalization
9367        // (`push_codex_item`, above).
9368        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9369        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9370        // records (e.g. a text-only narration turn immediately followed by a
9371        // bare tool-call turn, no user turn between — a real, common Claude
9372        // Code shape) each become their own Codex `message`/`function_call`
9373        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9374        // opportunistically RE-MERGES an assistant `message` immediately
9375        // followed by a `function_call` back into ONE `ChatMessage`, to match
9376        // how a genuinely single Claude turn (text+tool_use in the SAME
9377        // record) round-trips — but with no distinguishing signal, it can't
9378        // tell that case apart from two originally-separate records that
9379        // just happen to be adjacent, so it wrongly recombines them too,
9380        // silently shrinking the message count on every Claude -> Codex ->
9381        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9382        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9383        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9384        // itself emits. `push_codex_item`'s merge already treats a turn_id
9385        // mismatch as "different turn, do not merge" (the pre-existing
9386        // belt-and-suspenders check); real native Codex data almost never
9387        // carries this field (per that check's own comment), so this is a
9388        // no-op there and only sharpens fidelity for OUR OWN synthesized
9389        // export.
9390        let mut next_group_id: u64 = 0;
9391        // N2 (Fable-5 review, turn_id-collision hardening): every group id
9392        // this export has already assigned — whether REUSED from a real
9393        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9394        // `ChatMessage` never emits one that's already in use. Two concrete
9395        // mis-merge scenarios motivate this:
9396        //
9397        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9398        //     own text+tool_use); reload makes A carry REAL turn_id
9399        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9400        //     its own) is then appended. Re-export: A reuses its real
9401        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
9402        //     from `next_group_id == 0` again (nothing bumped it when A's id
9403        //     was reused rather than fabricated) — also `sc-grp-0`.
9404        //     Collision. If A's call has no output (interrupted session),
9405        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9406        //     adjacent with nothing to break the run and merges all three
9407        //     into ONE message (2 -> 1).
9408        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9409        //     truncation/clear event strips `__codex_open_turn` (closing the
9410        //     turn without changing the id), then `function_call(turn-7)`
9411        //     loads as a SECOND, separate `ChatMessage` that still carries
9412        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
9413        //     restamps it). Full-synthesis export naively reuses `turn-7`
9414        //     verbatim for BOTH messages (they're two different loop
9415        //     iterations, each independently reusing its own `real_turn_id`)
9416        //     and emits them adjacent — reimport's merge check can't tell
9417        //     this apart from a single message's own multi-call turn and
9418        //     recombines them (2 -> 1).
9419        //
9420        // Fix: the fabricated-id counter is advanced (skipped) past any id
9421        // already in `used_group_ids`, AND a real id that's already been
9422        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9423        // — never letting two DIFFERENT `ChatMessage`s in this export share
9424        // one group id, since `push_codex_item`'s merge check treats a
9425        // shared id as "same turn, merge". A single `ChatMessage`'s own
9426        // message record + its own tool call records still share ONE group
9427        // id (computed once per loop iteration below, before insertion), so
9428        // the D1 tool_search merge and ordinary same-turn multi-call
9429        // grouping are unaffected — this only stops REUSE across iterations.
9430        //
9431        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9432        // spliced-export tail is likewise blind-proof against the prefix it
9433        // doesn't itself write.
9434        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9435
9436        for msg in messages {
9437            if is_replay_excluded(msg) {
9438                continue;
9439            }
9440            // D3 (Fable-5 review): a message loaded FROM real native Codex
9441            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9442            // (`push_codex_item`'s "message" arm stamps it whenever the
9443            // source record itself has one). The group-id logic below used
9444            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9445            // silently overwriting/discarding that real id on any
9446            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9447            // when present; only fabricate a synthetic id as a fallback for
9448            // our own merge-disambiguation need (PARITY-6/7) when the
9449            // message has no real one of its own.
9450            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9451            match msg.role {
9452                Role::System => {
9453                    // PARITY-6 dev/02: carry the original Claude
9454                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9455                    // through as `metadata.claude_system_subtype`, so
9456                    // `push_codex_item`'s reverse load can restore it and
9457                    // `write_claude_code_records`'s `Role::System` arm can
9458                    // re-materialize the EXACT original subtype rather than
9459                    // guessing on a Codex -> Claude hop.
9460                    let subtype_meta = msg
9461                        .metadata
9462                        .get("systemSubtype")
9463                        .map(|s| ("claude_system_subtype", s.as_str()));
9464                    self.push_codex_message(
9465                        out,
9466                        "developer",
9467                        "input_text",
9468                        msg,
9469                        real_turn_id,
9470                        subtype_meta,
9471                    )
9472                }
9473                Role::User => {
9474                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9475                }
9476                Role::Assistant => {
9477                    // Emit the message record whenever there is text OR
9478                    // content_parts (IX-6 follow-up): an image-only assistant
9479                    // message has `content: None, content_parts:
9480                    // Some([image])` (the loader's `codex_extract_images` is
9481                    // role-general, so this shape can occur on the assistant
9482                    // side too) — gating on `msg.content` alone silently
9483                    // dropped the whole message, image included. A
9484                    // text-only message (content_parts: None) keeps taking
9485                    // the historical byte-identical path via
9486                    // `codex_message_content_blocks`'s `None` arm. A real
9487                    // empty native assistant record carries the
9488                    // loader's explicit marker and must also be emitted.
9489                    // Reasoning-only cross-provider turns deliberately lack
9490                    // that marker and keep the documented Codex residue.
9491                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9492                    let has_message_record = has_text
9493                        || msg.content_parts.is_some()
9494                        || msg.metadata.contains_key("empty_assistant_record");
9495                    // Only assign a synthetic group id when there's actual
9496                    // merge ambiguity to resolve (a message AND its own tool
9497                    // calls, or 2+ of this message's own tool calls) — a
9498                    // pure-text message with no tool calls, or a lone tool
9499                    // call with nothing else from the same `ChatMessage`,
9500                    // has nothing to disambiguate, so it keeps the exact
9501                    // historical byte shape (no `metadata` key at all).
9502                    let group_id: Option<String> = if let Some(real) = real_turn_id {
9503                        if used_group_ids.contains(real) {
9504                            // N2: this real turn_id was already used by an
9505                            // earlier (now-closed) `ChatMessage` in this same
9506                            // export — reusing it verbatim would let the
9507                            // reimport merge check recombine two originally
9508                            // separate messages (see the doc comment above).
9509                            let mut n = 1u64;
9510                            let mut candidate = format!("{real}~dup{n}");
9511                            while used_group_ids.contains(&candidate) {
9512                                n += 1;
9513                                candidate = format!("{real}~dup{n}");
9514                            }
9515                            Some(candidate)
9516                        } else {
9517                            Some(real.to_string())
9518                        }
9519                    } else if !msg.tool_calls().is_empty() {
9520                        // N2: skip past any id already used (e.g. a REAL
9521                        // turn_id that happens to look like `sc-grp-N`, or an
9522                        // id an earlier reused-real case landed on).
9523                        let mut candidate = format!("sc-grp-{next_group_id}");
9524                        next_group_id += 1;
9525                        while used_group_ids.contains(&candidate) {
9526                            candidate = format!("sc-grp-{next_group_id}");
9527                            next_group_id += 1;
9528                        }
9529                        Some(candidate)
9530                    } else {
9531                        None
9532                    };
9533                    if let Some(g) = &group_id {
9534                        used_group_ids.insert(g.clone());
9535                    }
9536                    if has_message_record {
9537                        self.push_codex_message(
9538                            out,
9539                            "assistant",
9540                            "output_text",
9541                            msg,
9542                            group_id.as_deref(),
9543                            None,
9544                        );
9545                    }
9546                    for tc in msg.tool_calls() {
9547                        let custom_tool_call = msg
9548                            .metadata
9549                            .get("codex_custom_tool_call_ids")
9550                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9551                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9552                        if custom_tool_call {
9553                            let input = tc
9554                                .function
9555                                .parsed_arguments()
9556                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9557                            let mut payload = with_turn_id(
9558                                serde_json::json!({
9559                                    "type": "custom_tool_call",
9560                                    "name": tc.function.name,
9561                                    "input": input,
9562                                    "call_id": tc.id,
9563                                }),
9564                                group_id.as_deref(),
9565                            );
9566                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9567                            push_jsonl(
9568                                out,
9569                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9570                            );
9571                        } else if tc.function.name == "tool_search" {
9572                            tool_search_call_ids.insert(tc.id.clone());
9573                            let mut payload = with_turn_id(
9574                                serde_json::json!({
9575                                    "type": "tool_search_call",
9576                                    "arguments": tc.function.arguments,
9577                                    "call_id": tc.id,
9578                                }),
9579                                group_id.as_deref(),
9580                            );
9581                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9582                            push_jsonl(
9583                                out,
9584                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9585                            );
9586                        } else {
9587                            let mut payload = with_turn_id(
9588                                serde_json::json!({
9589                                    "type": "function_call",
9590                                    "name": tc.function.name,
9591                                    "arguments": tc.function.arguments,
9592                                    "call_id": tc.id,
9593                                }),
9594                                group_id.as_deref(),
9595                            );
9596                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9597                            push_jsonl(
9598                                out,
9599                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9600                            );
9601                        }
9602                    }
9603                    // PARITY-11: a genuinely reasoning-only turn (Claude
9604                    // `thinking`/`redacted_thinking` with no text, tool_use,
9605                    // or image — `push_claude_assistant`'s load-side fix for
9606                    // the ~21% of real assistant records that are exactly
9607                    // this shape) has no message record and no tool calls,
9608                    // so nothing above writes anything for it. This is
9609                    // DELIBERATE, not a residual gap: Codex's `reasoning`
9610                    // response_item is understood on import (see the
9611                    // `response_item`/`"reasoning"` arm above), but its
9612                    // real-native semantics is "the reasoning immediately
9613                    // BEFORE the next turn" — the reader attaches it to
9614                    // whatever response_item comes next, unconditionally.
9615                    // For a genuinely standalone Claude reasoning-only turn
9616                    // (no related turn follows in Codex's export at all),
9617                    // emitting one here would get silently misattributed as
9618                    // belonging to some later, unrelated turn instead —
9619                    // strictly worse than the current honest, accounted-for
9620                    // absence (thinking/redacted_thinking is provider-
9621                    // private and "not replayed across providers" by
9622                    // original design; the audit correctly classifies it
9623                    // `Coverage::Dropped`, not `Unmodeled`). See the
9624                    // PARITY-6/7 corpus test's `is_replayable` filter for
9625                    // why this doesn't count as a message-count regression.
9626                }
9627                Role::Tool
9628                    if msg
9629                        .tool_call_id
9630                        .as_deref()
9631                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9632                {
9633                    let content = msg.content.clone().unwrap_or_default();
9634                    let tools =
9635                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9636                    let mut payload = serde_json::json!({
9637                        "type": "tool_search_output",
9638                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9639                        "tools": tools,
9640                    });
9641                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9642                    push_jsonl(
9643                        out,
9644                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9645                    );
9646                }
9647                Role::Tool => {
9648                    let mut payload = serde_json::json!({
9649                        "type": "function_call_output",
9650                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9651                        "output": codex_tool_output_text(msg),
9652                    });
9653                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9654                    push_jsonl(
9655                        out,
9656                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9657                    );
9658                }
9659            }
9660        }
9661    }
9662
9663    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9664    /// line, not just the `session_meta`/`turn_context` headers
9665    /// [`Self::to_codex_jsonl`] replays — overriding only
9666    /// `session_meta.payload.id` when `session_id` is `Some` (every other
9667    /// line, including `response_item`s the stock synthesis would otherwise
9668    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9669    /// `response_item` records only for the appended tail, via
9670    /// [`Self::write_codex_records`].
9671    fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9672        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9673
9674        let mut out = String::new();
9675        for line in &self.raw[..raw_prefix_len] {
9676            match session_id {
9677                Some(id) => {
9678                    let patched = serde_json::from_str::<Value>(line)
9679                        .ok()
9680                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9681                        .map(|mut v| {
9682                            if let Some(payload) = v.get_mut("payload") {
9683                                payload["id"] = Value::String(id.to_string());
9684                            }
9685                            v.to_string()
9686                        });
9687                    out.push_str(patched.as_deref().unwrap_or(line));
9688                }
9689                None => out.push_str(line),
9690            }
9691            out.push('\n');
9692        }
9693
9694        // N2 (spliced-path hardening): seed the tail's collision guard with
9695        // every group id the just-replayed RAW prefix already carries, so
9696        // `write_codex_records` never fabricates/reuses an id for the
9697        // appended tail that collides with one still open at the end of the
9698        // prefix (see that fn's doc comment, and
9699        // `collect_codex_group_ids_from_raw`'s).
9700        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9701        // Belt-and-suspenders: also union in the prefix `messages`' own
9702        // recorded `turn_id` metadata. In the ordinary case this is already
9703        // a subset of what the raw-line scan above found (the loader stamps
9704        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9705        // field the scan reads) — but scanning `messages` too costs nothing
9706        // and means this stays correct even if some future loader path ever
9707        // derives a message's `turn_id` by some means other than a literal
9708        // `payload.metadata.turn_id` copy.
9709        for msg in &self.messages[..message_prefix_len] {
9710            if let Some(tid) = msg.metadata.get("turn_id") {
9711                seed_used_ids.insert(tid.clone());
9712            }
9713        }
9714        self.write_codex_records(
9715            &mut out,
9716            &self.messages[message_prefix_len..],
9717            &seed_used_ids,
9718        );
9719        out
9720    }
9721
9722    /// Build a Codex header from scratch (used when converting from another
9723    /// format, where no original Codex header exists to replay). Emits the
9724    /// fields Codex requires on `session_meta`.
9725    fn write_synthesized_codex_header(&self, out: &mut String) {
9726        let mut meta_payload = serde_json::json!({
9727            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9728            "timestamp": SYNTH_TS,
9729            "cwd": self.cwd_string(),
9730            "originator": "supercode",
9731            "cli_version": env!("CARGO_PKG_VERSION"),
9732            "source": "exec",
9733            "thread_source": "user",
9734            "model_provider": "openai",
9735        });
9736        if let Some(sp) = &self.meta.system_prompt {
9737            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9738        }
9739        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9740        // `capture_claude_meta`) through the Codex hop under a clearly
9741        // namespaced custom field — real Codex tooling ignores unknown
9742        // `session_meta.payload` keys, and `capture_codex_session_meta`
9743        // reads this same key back on import, so a Claude -> Codex -> Claude
9744        // round trip still reconstructs the original record instead of
9745        // silently losing the lineage note on the cross-format hop.
9746        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9747            meta_payload["claude_fork_context_ref"] =
9748                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9749        }
9750        push_jsonl(
9751            out,
9752            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9753        );
9754        if let Some(model) = &self.meta.model {
9755            push_jsonl(
9756                out,
9757                &serde_json::json!({
9758                    "timestamp": SYNTH_TS,
9759                    "type": "turn_context",
9760                    "payload": {"model": model, "cwd": self.cwd_string()},
9761                }),
9762            );
9763        }
9764    }
9765
9766    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9767    /// [`Self::write_codex_records`] — `Some` when the source message
9768    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9769    /// (assistant only) a synthetic disambiguation id when it owns tool
9770    /// calls needing merge disambiguation and has no real id of its own;
9771    /// `None` reproduces the exact historical shape (no `metadata` key at
9772    /// all).
9773    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9774    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9775    /// `Role::System` case in [`Self::write_codex_records`] to carry
9776    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9777    /// system record's subtype survives the Claude -> Codex -> Claude round
9778    /// trip instead of only its text; `None` for every other caller,
9779    /// preserving the exact historical shape).
9780    fn push_codex_message(
9781        &self,
9782        out: &mut String,
9783        role: &str,
9784        text_type: &str,
9785        msg: &ChatMessage,
9786        turn_id: Option<&str>,
9787        extra_metadata: Option<(&str, &str)>,
9788    ) {
9789        let mut payload = with_turn_id(
9790            serde_json::json!({
9791                "type": "message",
9792                "role": role,
9793                "content": codex_message_content_blocks(text_type, msg),
9794            }),
9795            turn_id,
9796        );
9797        if let Some((k, v)) = extra_metadata {
9798            if payload.get("metadata").is_none() {
9799                payload["metadata"] = serde_json::json!({});
9800            }
9801            payload["metadata"][k] = serde_json::json!(v);
9802        }
9803        set_grok_message_extension(&mut payload, self.meta.source, msg);
9804        push_jsonl(
9805            out,
9806            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9807        );
9808    }
9809
9810    /// Synthesize a fresh pi v3 session from the canonical `messages`
9811    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9812    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9813    /// through `raw` + `to_native_jsonl(_v2)` instead).
9814    fn to_pi_jsonl(&self) -> String {
9815        let session_id = self
9816            .meta
9817            .session_id
9818            .clone()
9819            .unwrap_or_else(|| synth_uuid(0));
9820        let cwd = self.cwd_string();
9821        let mut out = String::new();
9822        push_pi_header(
9823            &mut out,
9824            &session_id,
9825            &cwd,
9826            self.meta
9827                .lineage
9828                .get("parent_session_path")
9829                .map(String::as_str),
9830            self.meta.lineage.get("created_at").map(String::as_str),
9831            // D7: carry a captured Claude `fork-context-ref` (see
9832            // `capture_claude_meta`) through the Pi hop too — mirrors the
9833            // Codex hop's `claude_fork_context_ref` passthrough
9834            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9835            // round trip doesn't silently lose fork lineage just because Pi
9836            // has no native slot for it.
9837            self.meta
9838                .lineage
9839                .get("claude_fork_context_ref_raw")
9840                .map(String::as_str),
9841        );
9842        let mut used_ids: HashSet<String> = HashSet::new();
9843        let mut counter: u64 = 0;
9844        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9845        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9846            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9847        }
9848        out
9849    }
9850
9851    /// Synthesize pi `message` entries for `messages` (a full session, or —
9852    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9853    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9854    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9855    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9856    fn write_pi_entries(
9857        &self,
9858        out: &mut String,
9859        messages: &[ChatMessage],
9860        mut parent: Option<String>,
9861        used_ids: &mut HashSet<String>,
9862        counter: &mut u64,
9863    ) {
9864        // Claude Code and Codex do not repeat the tool name on their native
9865        // tool-result records. Recover that redundant Pi field from the
9866        // paired assistant call when a cross-format round trip therefore
9867        // returns a canonical Tool message with `name == None`.
9868        let mut paired_tool_names = HashMap::<String, String>::new();
9869        for msg in messages {
9870            if is_replay_excluded(msg) {
9871                continue;
9872            }
9873            for call in msg.tool_calls() {
9874                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9875            }
9876            let id = pi_fresh_id(used_ids, counter);
9877            let mut entry = match msg.role {
9878                // B4: pi has no session-level system/developer PROMPT slot
9879                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9880                // content-bearing `Role::System` message loaded from a real
9881                // Claude Code `type: "system"` record (`push_claude_system`'s
9882                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9883                // `away_summary`) is NOT a system prompt — it's a real,
9884                // non-regenerable transcript event. Pi's own `role:"custom"`
9885                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9886                // as a user message") is the closest existing, non-fabricated
9887                // slot pi's own parser already understands, so this
9888                // re-materializes the record there instead of silently
9889                // dropping it — the exact allowance push_claude_system's own
9890                // doc comment describes in reverse. `customType` is a
9891                // supercode-namespaced marker (`push_pi_custom_common`
9892                // recognizes it on reload and restores `Role::System` +
9893                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9894                // produced in the first place); a real pi customType never
9895                // collides with this name. `details.claude_system_subtype`
9896                // carries the original subtype losslessly through the pi leg
9897                // (mirrors `write_codex_records`'s `claude_system_subtype`
9898                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9899                // is never fabricated — only emitted when non-empty.
9900                Role::System => {
9901                    let content = msg.content.clone().unwrap_or_default();
9902                    if content.trim().is_empty() {
9903                        continue;
9904                    }
9905                    let subtype = msg
9906                        .metadata
9907                        .get("systemSubtype")
9908                        .cloned()
9909                        .unwrap_or_else(|| "local_command".to_string());
9910                    serde_json::json!({
9911                        "type": "message",
9912                        "id": id,
9913                        "parentId": parent,
9914                        "timestamp": msg_timestamp_or_synth(msg),
9915                        "message": {
9916                            "role": "custom",
9917                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
9918                            "content": content,
9919                            "display": true,
9920                            "details": {"claude_system_subtype": subtype},
9921                            "timestamp": msg_pi_native_timestamp_ms(msg),
9922                        },
9923                    })
9924                }
9925                Role::User => serde_json::json!({
9926                    "type": "message",
9927                    "id": id,
9928                    "parentId": parent,
9929                    "timestamp": msg_timestamp_or_synth(msg),
9930                    "message": {
9931                        "role": "user",
9932                        "content": pi_content_value(msg),
9933                        "timestamp": msg_pi_native_timestamp_ms(msg),
9934                    },
9935                }),
9936                Role::Assistant => {
9937                    let api = msg
9938                        .metadata
9939                        .get("pi_api")
9940                        .cloned()
9941                        .unwrap_or_else(|| "anthropic-messages".to_string());
9942                    let provider = msg
9943                        .metadata
9944                        .get("pi_provider")
9945                        .cloned()
9946                        .unwrap_or_else(|| "anthropic".to_string());
9947                    let model = self
9948                        .meta
9949                        .model
9950                        .clone()
9951                        .unwrap_or_else(|| "unknown".to_string());
9952                    let usage = msg
9953                        .metadata
9954                        .get("pi_usage")
9955                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9956                        .unwrap_or_else(default_pi_usage);
9957                    let stop_reason = msg
9958                        .metadata
9959                        .get("pi_stop_reason")
9960                        .cloned()
9961                        .unwrap_or_else(|| "stop".to_string());
9962                    serde_json::json!({
9963                        "type": "message",
9964                        "id": id,
9965                        "parentId": parent,
9966                        "timestamp": msg_timestamp_or_synth(msg),
9967                        "message": {
9968                            "role": "assistant",
9969                            "content": pi_assistant_content_value(msg),
9970                            "api": api,
9971                            "provider": provider,
9972                            "model": model,
9973                            "usage": usage,
9974                            "stopReason": stop_reason,
9975                            "timestamp": msg_pi_native_timestamp_ms(msg),
9976                        },
9977                    })
9978                }
9979                Role::Tool => serde_json::json!({
9980                    "type": "message",
9981                    "id": id,
9982                    "parentId": parent,
9983                    "timestamp": msg_timestamp_or_synth(msg),
9984                    "message": {
9985                        "role": "toolResult",
9986                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
9987                        "toolName": msg.name.as_deref().or_else(|| {
9988                            msg.tool_call_id
9989                                .as_deref()
9990                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
9991                        }).unwrap_or_default(),
9992                        "content": pi_content_value(msg),
9993                        "isError": is_tool_error_flag(msg),
9994                        "timestamp": msg_pi_native_timestamp_ms(msg),
9995                    },
9996                }),
9997            };
9998            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9999                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
10000            }
10001            set_grok_message_extension(&mut entry, self.meta.source, msg);
10002            push_jsonl(out, &entry);
10003            parent = Some(id);
10004            if msg.role == Role::Tool {
10005                if let Some(call_id) = msg.tool_call_id.as_deref() {
10006                    paired_tool_names.remove(call_id);
10007                }
10008            }
10009        }
10010    }
10011
10012    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
10013    /// **verbatim** — the header line always has its `version` normalized to
10014    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
10015    /// byte-identity, so the writer never re-emits one; this intentionally
10016    /// breaks byte-identity for pre-v3 originals only, the accepted
10017    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
10018    /// other raw line — every entry — is untouched (pi repeats the session
10019    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
10020    /// entries only for the appended tail via [`Self::write_pi_entries`],
10021    /// chaining from the last entry `id` found in the raw prefix.
10022    fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10023        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10024        if raw_prefix_len == 0 {
10025            return Ok(self.to_pi_jsonl());
10026        }
10027
10028        let mut out = String::new();
10029        let mut used_ids: HashSet<String> = HashSet::new();
10030        let mut leaf: Option<String> = None;
10031        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10032            if i == 0 {
10033                if let Ok(v) = serde_json::from_str::<Value>(line) {
10034                    if v.get("type").and_then(Value::as_str) == Some("session") {
10035                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10036                        // Only reparse+reserialize the header when something
10037                        // actually needs to change — this crate doesn't
10038                        // enable serde_json's `preserve_order`, so a no-op
10039                        // round-trip through `Value` would reorder keys
10040                        // alphabetically and silently break the "prefix
10041                        // bytes unchanged" splice guarantee for the (common)
10042                        // already-v3, no-override case.
10043                        if needs_v3 || session_id.is_some() {
10044                            let mut v = v;
10045                            v["version"] = serde_json::json!(3);
10046                            if let Some(new_id) = session_id {
10047                                v["id"] = Value::String(new_id.to_string());
10048                            }
10049                            out.push_str(&v.to_string());
10050                            out.push('\n');
10051                            continue;
10052                        }
10053                    }
10054                }
10055            }
10056            out.push_str(line);
10057            out.push('\n');
10058            if let Ok(v) = serde_json::from_str::<Value>(line) {
10059                if let Some(id) = v.get("id").and_then(Value::as_str) {
10060                    used_ids.insert(id.to_string());
10061                    leaf = Some(id.to_string());
10062                }
10063            }
10064        }
10065
10066        let mut counter: u64 = 0;
10067        self.write_pi_entries(
10068            &mut out,
10069            &self.messages[message_prefix_len..],
10070            leaf,
10071            &mut used_ids,
10072            &mut counter,
10073        );
10074        Ok(out)
10075    }
10076
10077    // ---- Grok writers -----------------------------------------------
10078
10079    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10080    fn to_grok_jsonl(&self) -> String {
10081        let mut out = String::new();
10082        if let Some(prompt) = self
10083            .meta
10084            .system_prompt
10085            .as_deref()
10086            .filter(|prompt| !prompt.is_empty())
10087        {
10088            push_jsonl(
10089                &mut out,
10090                &serde_json::json!({
10091                    "type": "system",
10092                    "content": prompt,
10093                }),
10094            );
10095        }
10096        self.write_grok_records(&mut out, &self.messages);
10097        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10098            if out.is_empty() {
10099                push_jsonl(
10100                    &mut out,
10101                    &serde_json::json!({"type": "system", "content": ""}),
10102                );
10103            }
10104            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10105        }
10106        out
10107    }
10108
10109    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10110        for message in messages {
10111            if is_replay_excluded(message) {
10112                continue;
10113            }
10114            let mut value = match message.role {
10115                Role::System => serde_json::json!({
10116                    "type": "user",
10117                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10118                    "synthetic_reason": "supercode_system_event",
10119                }),
10120                Role::User => {
10121                    let mut value = serde_json::json!({
10122                        "type": "user",
10123                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10124                    });
10125                    if let Some(object) = value.as_object_mut() {
10126                        for (metadata, field) in [
10127                            ("grok_prompt_index", "prompt_index"),
10128                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10129                            ("grok_synthetic_reason", "synthetic_reason"),
10130                        ] {
10131                            if let Some(raw) = message.metadata.get(metadata) {
10132                                object.insert(
10133                                    field.to_string(),
10134                                    serde_json::from_str(raw)
10135                                        .unwrap_or_else(|_| Value::String(raw.clone())),
10136                                );
10137                            }
10138                        }
10139                    }
10140                    value
10141                }
10142                Role::Assistant => {
10143                    let calls = message
10144                        .tool_calls()
10145                        .iter()
10146                        .map(|call| {
10147                            serde_json::json!({
10148                                "id": call.id,
10149                                "name": call.function.name,
10150                                "arguments": call.function.arguments,
10151                            })
10152                        })
10153                        .collect::<Vec<_>>();
10154                    let mut value = serde_json::json!({
10155                        "type": "assistant",
10156                        "content": message.content.clone().unwrap_or_default(),
10157                        "tool_calls": calls,
10158                        "model_id": message.metadata.get("grok_model_id")
10159                            .or(self.meta.model.as_ref())
10160                            .cloned()
10161                            .unwrap_or_else(|| "unknown".to_string()),
10162                    });
10163                    if let Some(object) = value.as_object_mut() {
10164                        for (metadata, field) in [
10165                            ("grok_model_fingerprint", "model_fingerprint"),
10166                            ("grok_reasoning_effort", "reasoning_effort"),
10167                        ] {
10168                            if let Some(raw) = message.metadata.get(metadata) {
10169                                object.insert(field.to_string(), Value::String(raw.clone()));
10170                            }
10171                        }
10172                    }
10173                    value
10174                }
10175                Role::Tool => serde_json::json!({
10176                    "type": "tool_result",
10177                    "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10178                    "content": message.content.clone().unwrap_or_default(),
10179                }),
10180            };
10181            set_grok_target_message_extension(&mut value, message);
10182            push_jsonl(out, &value);
10183        }
10184    }
10185
10186    /// Replay a Grok imported prefix verbatim, then append newly-created
10187    /// canonical turns. Grok stores the session id in the directory name,
10188    /// not in transcript records, so there is no in-file id to rewrite.
10189    fn to_grok_jsonl_spliced(&self) -> String {
10190        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10191        if raw_prefix_len == 0 {
10192            return self.to_grok_jsonl();
10193        }
10194        let mut out = String::new();
10195        for line in &self.raw[..raw_prefix_len] {
10196            out.push_str(line);
10197            out.push('\n');
10198        }
10199        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10200        out
10201    }
10202
10203    // ---- Gemini writers ---------------------------------------------
10204
10205    fn to_gemini_jsonl(&self) -> String {
10206        let mut out = String::new();
10207        self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10208        self.write_gemini_records(&mut out, &self.messages);
10209        push_jsonl(
10210            &mut out,
10211            &serde_json::json!({
10212                "$set": {"lastUpdated": SYNTH_TS}
10213            }),
10214        );
10215        out
10216    }
10217
10218    fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10219        push_jsonl(
10220            out,
10221            &serde_json::json!({
10222                "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10223                "projectHash": self.meta.lineage.get("gemini_project_hash")
10224                    .cloned().unwrap_or_else(|| "supercode".to_string()),
10225                "startTime": self.meta.lineage.get("created_at")
10226                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10227                "lastUpdated": self.meta.lineage.get("updated_at")
10228                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10229                "kind": self.meta.lineage.get("gemini_session_kind")
10230                    .cloned().unwrap_or_else(|| "main".to_string()),
10231            }),
10232        );
10233    }
10234
10235    fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10236        let mut call_names = HashMap::new();
10237        for (index, message) in messages.iter().enumerate() {
10238            if is_replay_excluded(message) {
10239                continue;
10240            }
10241            let timestamp = message
10242                .metadata
10243                .get("timestamp")
10244                .cloned()
10245                .unwrap_or_else(|| SYNTH_TS.to_string());
10246            match message.role {
10247                Role::System | Role::User => {
10248                    let mut parts = Vec::new();
10249                    let text = message.content.clone().or_else(|| {
10250                        message.content_parts.as_ref().and_then(|parts| {
10251                            let text = parts
10252                                .iter()
10253                                .filter_map(|part| part.get("text").and_then(Value::as_str))
10254                                .collect::<Vec<_>>()
10255                                .join(" ");
10256                            (!text.is_empty()).then_some(text)
10257                        })
10258                    });
10259                    if let Some(text) = text {
10260                        let text = if message.role == Role::System {
10261                            format!("[System] {text}")
10262                        } else {
10263                            text
10264                        };
10265                        parts.push(serde_json::json!({"text": text}));
10266                    }
10267                    if let Some(content_parts) = &message.content_parts {
10268                        for part in content_parts {
10269                            let Some(url) = part
10270                                .get("image_url")
10271                                .and_then(|value| value.get("url"))
10272                                .and_then(Value::as_str)
10273                            else {
10274                                continue;
10275                            };
10276                            let Some(rest) = url.strip_prefix("data:") else {
10277                                continue;
10278                            };
10279                            let Some((media_type, data)) = rest.split_once(";base64,") else {
10280                                continue;
10281                            };
10282                            parts.push(serde_json::json!({
10283                                "inlineData": {"mimeType": media_type, "data": data}
10284                            }));
10285                        }
10286                    }
10287                    if !parts.is_empty() {
10288                        let mut value = serde_json::json!({
10289                            "id": format!("supercode-user-{index}"),
10290                            "timestamp": timestamp,
10291                            "type": "user",
10292                            "content": parts,
10293                        });
10294                        set_gemini_message_extension(&mut value, message);
10295                        push_jsonl(out, &value);
10296                    }
10297                }
10298                Role::Assistant => {
10299                    let mut tool_calls = Vec::new();
10300                    for call in message.tool_calls() {
10301                        call_names.insert(call.id.clone(), call.function.name.clone());
10302                        let args = serde_json::from_str::<Value>(&call.function.arguments)
10303                            .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10304                        tool_calls.push(serde_json::json!({
10305                            "id": call.id,
10306                            "name": call.function.name,
10307                            "args": args,
10308                        }));
10309                    }
10310                    let mut value = serde_json::json!({
10311                        "id": format!("supercode-gemini-{index}"),
10312                        "timestamp": timestamp,
10313                        "type": "gemini",
10314                        "content": message.content.clone().unwrap_or_default(),
10315                        "model": message.metadata.get("gemini_model")
10316                            .or(self.meta.model.as_ref())
10317                            .cloned().unwrap_or_else(|| "unknown".to_string()),
10318                    });
10319                    if !tool_calls.is_empty() {
10320                        value["toolCalls"] = Value::Array(tool_calls);
10321                    }
10322                    if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10323                        value["thoughts"] = serde_json::from_str(thoughts)
10324                            .unwrap_or_else(|_| Value::String(thoughts.clone()));
10325                    }
10326                    set_gemini_message_extension(&mut value, message);
10327                    push_jsonl(out, &value);
10328                }
10329                Role::Tool => {
10330                    let id = message.tool_call_id.clone().unwrap_or_default();
10331                    let name = message
10332                        .name
10333                        .clone()
10334                        .or_else(|| call_names.get(&id).cloned())
10335                        .unwrap_or_else(|| "tool".to_string());
10336                    let output = message.content.clone().unwrap_or_else(|| {
10337                        message
10338                            .content_parts
10339                            .as_ref()
10340                            .map(|parts| Value::Array(parts.clone()))
10341                            .map(|value| value.to_string())
10342                            .unwrap_or_default()
10343                    });
10344                    let mut value = serde_json::json!({
10345                        "id": format!("supercode-tool-{index}"),
10346                        "timestamp": timestamp,
10347                        "type": "user",
10348                        "content": [{
10349                            "functionResponse": {
10350                                "id": id,
10351                                "name": name,
10352                                "response": {"output": output}
10353                            }
10354                        }],
10355                    });
10356                    set_gemini_message_extension(&mut value, message);
10357                    push_jsonl(out, &value);
10358                }
10359            }
10360        }
10361    }
10362
10363    fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10364        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10365        if raw_prefix_len == 0 {
10366            let mut out = String::new();
10367            self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10368            self.write_gemini_records(&mut out, &self.messages);
10369            return out;
10370        }
10371        let mut out = String::new();
10372        for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10373            if index == 0 && session_id.is_some() {
10374                if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10375                    if value.get("type").is_none() && value.get("sessionId").is_some() {
10376                        value["sessionId"] =
10377                            Value::String(session_id.unwrap_or_default().to_string());
10378                        push_jsonl(&mut out, &value);
10379                        continue;
10380                    }
10381                }
10382            }
10383            out.push_str(line);
10384            out.push('\n');
10385        }
10386        self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10387        out
10388    }
10389
10390    // ---- Goose writers ----------------------------------------------
10391
10392    fn to_goose_json(&self) -> String {
10393        if self.meta.source == SessionSource::Goose
10394            && !self.raw.is_empty()
10395            && self.imported_message_count == Some(self.messages.len())
10396        {
10397            return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10398        }
10399        self.synthesized_goose_document(None, &self.messages)
10400    }
10401
10402    fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10403        let message_prefix_len = self
10404            .imported_message_count
10405            .unwrap_or(self.messages.len())
10406            .min(self.messages.len());
10407        if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10408            if session_id.is_none() && message_prefix_len == self.messages.len() {
10409                return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10410            }
10411            let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10412            if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10413                if let Some(session_id) = session_id {
10414                    document["id"] = Value::String(session_id.to_string());
10415                }
10416                let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10417                if let Some(conversation) = document
10418                    .get_mut("conversation")
10419                    .and_then(Value::as_array_mut)
10420                {
10421                    conversation.extend(appended);
10422                    document["message_count"] = Value::from(conversation.len());
10423                }
10424                return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10425                    self.synthesized_goose_document(session_id, &self.messages)
10426                });
10427            }
10428        }
10429        self.synthesized_goose_document(session_id, &self.messages)
10430    }
10431
10432    fn synthesized_goose_document(
10433        &self,
10434        session_id: Option<&str>,
10435        messages: &[ChatMessage],
10436    ) -> String {
10437        let mut document = self
10438            .meta
10439            .goose_header
10440            .clone()
10441            .or_else(|| {
10442                self.messages.iter().find_map(|message| {
10443                    message
10444                        .metadata
10445                        .get("goose_session_header")
10446                        .and_then(|value| serde_json::from_str(value).ok())
10447                })
10448            })
10449            .unwrap_or_else(|| {
10450                serde_json::json!({
10451                    "id": "supercode-goose-session",
10452                    "working_dir": self.cwd_string(),
10453                    "name": "supercode export",
10454                    "user_set_name": false,
10455                    "session_type": "user",
10456                    "created_at": SYNTH_TS,
10457                    "updated_at": SYNTH_TS,
10458                    "extension_data": {},
10459                    "usage": {},
10460                    "accumulated_usage": {},
10461                    "accumulated_cost": Value::Null,
10462                    "schedule_id": Value::Null,
10463                    "recipe": Value::Null,
10464                    "user_recipe_values": Value::Null,
10465                    "message_count": 0,
10466                    "last_message_at": Value::Null,
10467                    "provider_name": Value::Null,
10468                    "model_config": Value::Null,
10469                    "goose_mode": "auto",
10470                    "archived_at": Value::Null,
10471                    "project_id": Value::Null,
10472                    "parent_session_id": Value::Null,
10473                    "last_message_snippet": Value::Null,
10474                })
10475            });
10476        document["id"] = Value::String(
10477            session_id
10478                .map(str::to_string)
10479                .or_else(|| self.meta.session_id.clone())
10480                .unwrap_or_else(|| "supercode-goose-session".to_string()),
10481        );
10482        document["working_dir"] = Value::String(self.cwd_string());
10483        let conversation = self.goose_conversation(messages);
10484        document["message_count"] = Value::from(conversation.len());
10485        document["conversation"] = Value::Array(conversation);
10486        serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10487    }
10488
10489    fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10490        let mut out = Vec::new();
10491        let mut last_native_index: Option<String> = None;
10492        let mut tool_names = HashMap::<String, String>::new();
10493        for (index, message) in messages.iter().enumerate() {
10494            if is_replay_excluded(message) {
10495                continue;
10496            }
10497            if let Some(native_index) = message.metadata.get("goose_native_index") {
10498                if last_native_index.as_ref() == Some(native_index) {
10499                    continue;
10500                }
10501                last_native_index = Some(native_index.clone());
10502                if let Some(native) = message
10503                    .metadata
10504                    .get("goose_native_message")
10505                    .and_then(|value| serde_json::from_str::<Value>(value).ok())
10506                {
10507                    out.push(native);
10508                    continue;
10509                }
10510            } else {
10511                last_native_index = None;
10512            }
10513
10514            for call in message.tool_calls() {
10515                tool_names.insert(call.id.clone(), call.function.name.clone());
10516            }
10517            let created = message
10518                .metadata
10519                .get("goose_created")
10520                .and_then(|value| value.parse::<i64>().ok())
10521                .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10522            let role = match message.role {
10523                Role::Assistant => "assistant",
10524                _ => "user",
10525            };
10526            let mut content = Vec::new();
10527            // A Goose tool response carries its output inside
10528            // `toolResult.value.content`; duplicating it as a sibling text
10529            // block makes the loader normalize one Tool message twice.
10530            if message.role != Role::Tool {
10531                if let Some(text) = &message.content {
10532                    let text = if message.role == Role::System {
10533                        format!("[System] {text}")
10534                    } else {
10535                        text.clone()
10536                    };
10537                    content.push(serde_json::json!({"type": "text", "text": text}));
10538                }
10539                if let Some(parts) = &message.content_parts {
10540                    for part in parts {
10541                        if let Some(text) = part.get("text").and_then(Value::as_str) {
10542                            if message.content.is_none() {
10543                                content.push(serde_json::json!({"type": "text", "text": text}));
10544                            }
10545                        }
10546                        let Some(url) = part
10547                            .get("image_url")
10548                            .and_then(|image| image.get("url"))
10549                            .and_then(Value::as_str)
10550                        else {
10551                            continue;
10552                        };
10553                        let Some(data) = url.strip_prefix("data:") else {
10554                            continue;
10555                        };
10556                        let Some((media_type, data)) = data.split_once(";base64,") else {
10557                            continue;
10558                        };
10559                        content.push(serde_json::json!({
10560                            "type": "image",
10561                            "data": data,
10562                            "mimeType": media_type,
10563                        }));
10564                    }
10565                }
10566            }
10567            for call in message.tool_calls() {
10568                let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10569                    .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10570                content.push(serde_json::json!({
10571                    "type": "toolRequest",
10572                    "id": call.id,
10573                    "toolCall": {
10574                        "status": "success",
10575                        "value": {"name": call.function.name, "arguments": arguments}
10576                    }
10577                }));
10578            }
10579            if message.role == Role::Tool {
10580                let id = message.tool_call_id.clone().unwrap_or_default();
10581                let output = message.content.clone().unwrap_or_else(|| {
10582                    message
10583                        .content_parts
10584                        .as_ref()
10585                        .map(|parts| Value::Array(parts.clone()).to_string())
10586                        .unwrap_or_default()
10587                });
10588                let tool_result = if crate::is_tool_error(message) {
10589                    serde_json::json!({"status": "error", "error": output})
10590                } else {
10591                    serde_json::json!({
10592                        "status": "success",
10593                        "value": {
10594                            "content": [{"type": "text", "text": output}],
10595                            "isError": false
10596                        }
10597                    })
10598                };
10599                content.push(serde_json::json!({
10600                    "type": "toolResponse",
10601                    "id": id,
10602                    "toolResult": tool_result,
10603                    "metadata": {
10604                        "toolName": message.name.as_ref()
10605                            .or_else(|| tool_names.get(&id))
10606                    }
10607                }));
10608            }
10609            if content.is_empty() {
10610                continue;
10611            }
10612            let metadata = message
10613                .metadata
10614                .get("goose_metadata")
10615                .and_then(|value| serde_json::from_str::<Value>(value).ok())
10616                .unwrap_or_else(|| {
10617                    serde_json::json!({
10618                        "userVisible": true,
10619                        "agentVisible": true
10620                    })
10621                });
10622            let mut native = serde_json::json!({
10623                "id": message.metadata.get("goose_message_id")
10624                    .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10625                "role": role,
10626                "created": created,
10627                "content": content,
10628                "metadata": metadata,
10629            });
10630            // Goose tolerates unknown top-level fields on a conversation
10631            // message. Always carry the canonical envelope when Goose is
10632            // the TARGET so metadata absent from Goose's stock schema can
10633            // make a later Goose -> source round trip without residue.
10634            set_grok_target_message_extension(&mut native, message);
10635            out.push(native);
10636        }
10637        out
10638    }
10639
10640    // ---- OpenCode writers ---------------------------------------------
10641
10642    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10643    /// `(message value, part values)` list) directly from `self.raw`'s
10644    /// envelope lines — the same classification
10645    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10646    /// rather than canonical `ChatMessage`s. Used by
10647    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10648    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10649    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10650    /// path for excess keys/timestamps/side-records `opencode import`
10651    /// cannot restore).
10652    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10653        let mut session_info: Option<Value> = None;
10654        let mut msg_order: Vec<String> = Vec::new();
10655        let mut msg_values: HashMap<String, Value> = HashMap::new();
10656        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10657        for line in &self.raw {
10658            let Ok(env) = serde_json::from_str::<Value>(line) else {
10659                continue;
10660            };
10661            let Some(key) = env.get("key").and_then(Value::as_array) else {
10662                continue;
10663            };
10664            let value = env.get("value").cloned().unwrap_or(Value::Null);
10665            match key.first().and_then(Value::as_str) {
10666                Some("session") => session_info = Some(value),
10667                Some("message") => {
10668                    if let Some(id) = value.get("id").and_then(Value::as_str) {
10669                        if !msg_values.contains_key(id) {
10670                            msg_order.push(id.to_string());
10671                        }
10672                        msg_values.insert(id.to_string(), value);
10673                    }
10674                }
10675                Some("part") => {
10676                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10677                        msg_parts.entry(mid.to_string()).or_default().push(value);
10678                    }
10679                }
10680                _ => {}
10681            }
10682        }
10683        let mut ordered: Vec<(String, i64)> = msg_order
10684            .iter()
10685            .map(|id| {
10686                let tc = msg_values
10687                    .get(id)
10688                    .and_then(|v| v.get("time"))
10689                    .and_then(|t| t.get("created"))
10690                    .and_then(Value::as_i64)
10691                    .unwrap_or(0);
10692                (id.clone(), tc)
10693            })
10694            .collect();
10695        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10696        let mut out = Vec::new();
10697        for (id, _) in ordered {
10698            let mut parts = msg_parts.remove(&id).unwrap_or_default();
10699            parts.sort_by(|a, b| {
10700                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10701                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10702                ai.cmp(bi)
10703            });
10704            if let Some(v) = msg_values.remove(&id) {
10705                out.push((v, parts));
10706            }
10707        }
10708        (session_info, out)
10709    }
10710
10711    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10712    /// `raw` prefix exists to replay (a fresh/cross-format-converted
10713    /// session). T3 tier: only what `SessionMeta` carries survives.
10714    fn synthesized_opencode_info(&self) -> Value {
10715        let id = self
10716            .meta
10717            .session_id
10718            .clone()
10719            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10720        let mut info = serde_json::json!({
10721            "id": id,
10722            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10723            // OpenCode 1.2.15's import path writes this into a NOT NULL
10724            // SQLite column. Preserve a real source slug when available and
10725            // mint a stable, human-readable fallback for foreign sessions.
10726            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10727            "directory": self.cwd_string(),
10728            "title": "supercode export",
10729            "version": env!("CARGO_PKG_VERSION"),
10730            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10731        });
10732        if let Some(agent) = &self.meta.agent_id {
10733            info["agent"] = Value::String(agent.clone());
10734        }
10735        if let Some(model) = &self.meta.model {
10736            if let Some((provider, mid)) = model.split_once('/') {
10737                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10738            }
10739        }
10740        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10741            info["parentID"] = Value::String(parent.clone());
10742        }
10743        // D7: carry a captured Claude `fork-context-ref` through the
10744        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10745        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10746        // the `session` header) hops already do — namespaced so real
10747        // OpenCode tooling ignores it, and `capture_opencode_session_info`
10748        // reads this same key back on import so a Claude -> OpenCode ->
10749        // Claude round trip doesn't silently lose fork lineage either.
10750        //
10751        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10752        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10753        // this `claude_fork_context_ref` key on `SessionInfo` survives
10754        // supercode's OWN round-trip (write here, read back by
10755        // `capture_opencode_session_info` above) but NOT a real upstream
10756        // `opencode import` ingestion — that path decodes with
10757        // `Schema.decodeUnknownSync`, which strips any key its schema
10758        // doesn't declare. The direct-file/DB fallback (bypassing
10759        // `opencode import` entirely) is the per-spec fidelity path for
10760        // this lineage to actually reach real OpenCode.
10761        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10762            info["claude_fork_context_ref"] =
10763                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10764        }
10765        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10766            info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10767        }
10768        info
10769    }
10770
10771    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10772    /// synthesized continuation message therefore has to advance the
10773    /// session clock along with its own `time.created` value.
10774    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10775        if !info.get("time").is_some_and(Value::is_object) {
10776            info["time"] = serde_json::json!({});
10777        }
10778        info["time"]["updated"] = serde_json::json!(timestamp);
10779    }
10780
10781    /// Synthesize opencode `{info, parts}` message objects for `messages`
10782    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10783    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10784    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10785    /// back into its call's assistant `tool` part (match by
10786    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10787    /// whole slice)
10788    /// — the exact inverse of the loader's call/result split. This is a
10789    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10790    /// immediately following each assistant: two-or-more consecutive
10791    /// assistant-with-tool-call messages before their results (streamed /
10792    /// parallel tool calls) otherwise strand the earlier call's real result
10793    /// behind a later assistant message, silently downgrading it to
10794    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10795    /// messages exactly like every other writer.
10796    fn append_synthesized_opencode_messages(
10797        &self,
10798        out: &mut Vec<Value>,
10799        messages: &[ChatMessage],
10800        session_id: &str,
10801        counter: &mut u64,
10802        timestamp_cursor: &mut i64,
10803    ) -> Result<()> {
10804        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10805        // over the ENTIRE slice being processed, rather than by scanning
10806        // only the contiguous run of `Role::Tool` messages immediately
10807        // following a given assistant message. Two-or-more consecutive
10808        // assistant-with-tool-call messages before their results (streamed
10809        // / parallel tool calls — extremely common in real Claude Code and
10810        // Codex sessions) break the contiguous-run assumption: the first
10811        // assistant's own result(s) land AFTER a second assistant message,
10812        // not immediately after the first, so a contiguous scan starting
10813        // right after the first assistant finds nothing and silently drops
10814        // its real tool output into the `None => "pending"` branch below.
10815        // A single `id -> result` map is still insufficient: long real
10816        // sessions can reuse provider call ids. Last-write-wins then attaches
10817        // the final output to every earlier occurrence. Collect calls and
10818        // results independently and zip their occurrences in transcript
10819        // order, giving every concrete call position its own result.
10820        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10821        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10822        for (message_index, message) in messages.iter().enumerate() {
10823            if message.role == Role::Assistant {
10824                for (tool_index, call) in message.tool_calls().iter().enumerate() {
10825                    calls_by_id
10826                        .entry(call.id.as_str())
10827                        .or_default()
10828                        .push((message_index, tool_index));
10829                }
10830            } else if message.role == Role::Tool {
10831                if let Some(id) = &message.tool_call_id {
10832                    results_by_id
10833                        .entry(id.as_str())
10834                        .or_default()
10835                        .push((message_index, message));
10836                }
10837            }
10838        }
10839        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10840        for (id, calls) in calls_by_id {
10841            let Some(results) = results_by_id.get(id) else {
10842                continue;
10843            };
10844            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10845                paired_results.insert(call_position, result);
10846            }
10847        }
10848        let mut i = 0;
10849        while i < messages.len() {
10850            let msg = &messages[i];
10851            if is_replay_excluded(msg) {
10852                i += 1;
10853                continue;
10854            }
10855            match msg.role {
10856                // B4: opencode V1 has no session-level system-PROMPT slot
10857                // either — `User.system` is a per-turn system-PROMPT
10858                // OVERRIDE (§2.1), a different thing from a content-bearing
10859                // `Role::System` message loaded from a real Claude `type:
10860                // "system"` record (`push_claude_system`'s keep-listed
10861                // subtypes). Stuffing real transcript content into
10862                // `User.system` would be a genuine misuse — it overrides the
10863                // replayed system prompt, not just annotates a turn — so
10864                // this instead reuses opencode's own `text` part `synthetic`
10865                // flag (§3.1: "injected by opencode, not typed by user"),
10866                // which is EXACTLY the right existing, non-fabricated
10867                // semantic for "system-originated content presented as a
10868                // user turn": a dedicated `User` message with one
10869                // `synthetic: true` text part, tagged with a
10870                // supercode-namespaced part-`metadata` key so
10871                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10872                // recognize it on reload and restore `Role::System` +
10873                // `metadata["systemSubtype"]` rather than treating it as a
10874                // real user turn. Content is never fabricated — only
10875                // emitted when non-empty.
10876                Role::System => {
10877                    let content = msg.content.clone().unwrap_or_default();
10878                    if content.trim().is_empty() {
10879                        i += 1;
10880                        continue;
10881                    }
10882                    let subtype = msg
10883                        .metadata
10884                        .get("systemSubtype")
10885                        .cloned()
10886                        .unwrap_or_else(|| "local_command".to_string());
10887                    let msg_id = opencode_fresh_id("msg", counter);
10888                    let part_id = opencode_fresh_id("prt", counter);
10889                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10890                    let mut info = serde_json::json!({
10891                        "id": msg_id,
10892                        "sessionID": session_id,
10893                        "role": "user",
10894                        "time": {"created": timestamp},
10895                    });
10896                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10897                    let parts = vec![serde_json::json!({
10898                        "id": part_id,
10899                        "sessionID": session_id,
10900                        "messageID": msg_id,
10901                        "type": "text",
10902                        "text": content,
10903                        "synthetic": true,
10904                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
10905                    })];
10906                    out.push(serde_json::json!({"info": info, "parts": parts}));
10907                    i += 1;
10908                }
10909                Role::User => {
10910                    let msg_id = opencode_fresh_id("msg", counter);
10911                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
10912                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10913                    let mut info = serde_json::json!({
10914                        "id": msg_id,
10915                        "sessionID": session_id,
10916                        "role": "user",
10917                        "time": {"created": timestamp},
10918                    });
10919                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10920                    opencode_restore_agent_model_fields(
10921                        &mut info, msg, /* is_assistant */ false,
10922                    );
10923                    set_grok_message_extension(&mut info, self.meta.source, msg);
10924                    out.push(serde_json::json!({
10925                        "info": info,
10926                        "parts": parts,
10927                    }));
10928                    i += 1;
10929                }
10930                Role::Assistant => {
10931                    let msg_id = opencode_fresh_id("msg", counter);
10932                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10933                    let mut parts = Vec::new();
10934                    if let Some(thinking) = msg.metadata.get("thinking") {
10935                        let mut part = serde_json::json!({
10936                            "id": opencode_fresh_id("prt", counter),
10937                            "sessionID": session_id,
10938                            "messageID": msg_id,
10939                            "type": "reasoning",
10940                            "text": thinking,
10941                            // Required by OpenCode V1's native reasoning
10942                            // schema. A synthesized part has no distinct
10943                            // stream start/end, so the source message clock
10944                            // is the honest zero-duration span.
10945                            "time": {"start": timestamp, "end": timestamp},
10946                        });
10947                        if let Some(signature) = msg.metadata.get("thinking_signature") {
10948                            part["metadata"] = serde_json::json!({
10949                                "anthropic": {"signature": signature},
10950                            });
10951                        }
10952                        parts.push(part);
10953                    }
10954                    if let Some(t) = &msg.content {
10955                        if !t.is_empty() {
10956                            parts.push(serde_json::json!({
10957                                "id": opencode_fresh_id("prt", counter),
10958                                "sessionID": session_id,
10959                                "messageID": msg_id,
10960                                "type": "text",
10961                                "text": t,
10962                            }));
10963                        }
10964                    }
10965                    // Fold each tool call's result back into ONE `tool`
10966                    // part, matched by tool_call_id via the GLOBAL
10967                    // `all_results` map built above (not a contiguous scan)
10968                    // — a result may be many messages away when other
10969                    // assistant turns with their own pending calls
10970                    // intervene before it appears.
10971                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
10972                        let input = tc
10973                            .function
10974                            .parsed_arguments()
10975                            .unwrap_or_else(|_| Value::Object(Default::default()));
10976                        let paired_result = paired_results.get(&(i, tool_index)).copied();
10977                        let state = match paired_result {
10978                            Some((_, result)) if crate::is_tool_error(result) => {
10979                                let result_timestamp =
10980                                    opencode_message_timestamp(result, timestamp_cursor)?;
10981                                serde_json::json!({
10982                                    "status": "error",
10983                                    "input": input,
10984                                    "error": result.content.clone().unwrap_or_default(),
10985                                    "time": {"end": result_timestamp},
10986                                })
10987                            }
10988                            Some((_, result)) => {
10989                                let result_timestamp =
10990                                    opencode_message_timestamp(result, timestamp_cursor)?;
10991                                let mut s = serde_json::json!({
10992                                    "status": "completed",
10993                                    "input": input,
10994                                    "output": result.content.clone().unwrap_or_default(),
10995                                    "title": tc.function.name,
10996                                    "time": {"end": result_timestamp},
10997                                });
10998                                // PARITY-11 (nested images): the LOADER already
10999                                // reads a completed tool part's
11000                                // `state.attachments` back into `content_parts`
11001                                // (`opencode_file_image_part`, above) — this is
11002                                // the missing WRITE-side inverse. Without it, a
11003                                // Claude `tool_result`'s nested image (now
11004                                // captured into `content_parts` by
11005                                // `extract_tool_result_content`) reached
11006                                // `content_parts` on the canonical `ChatMessage`
11007                                // but was silently dropped again on re-export to
11008                                // OpenCode, because nothing ever read it back
11009                                // out. `mime`/`url` shape matches exactly what
11010                                // `opencode_file_image_part` expects on reload.
11011                                if let Some(cps) = &result.content_parts {
11012                                    let atts: Vec<Value> = cps
11013                                        .iter()
11014                                        .filter(|p| {
11015                                            p.get("type").and_then(Value::as_str)
11016                                                == Some("image_url")
11017                                        })
11018                                        .filter_map(|p| {
11019                                            let url = p
11020                                                .get("image_url")
11021                                                .and_then(|u| u.get("url"))
11022                                                .and_then(Value::as_str)?;
11023                                            let mime = url
11024                                                .strip_prefix("data:")
11025                                                .and_then(|r| r.split_once(','))
11026                                                .map(|(m, _)| m.trim_end_matches(";base64"))
11027                                                .unwrap_or("application/octet-stream");
11028                                            Some(serde_json::json!({
11029                                                "mime": mime,
11030                                                "url": url,
11031                                            }))
11032                                        })
11033                                        .collect();
11034                                    if !atts.is_empty() {
11035                                        s["attachments"] = Value::Array(atts);
11036                                    }
11037                                }
11038                                s
11039                            }
11040                            None => serde_json::json!({"status": "pending", "input": input}),
11041                        };
11042                        let mut part = serde_json::json!({
11043                            "id": opencode_fresh_id("prt", counter),
11044                            "sessionID": session_id,
11045                            "messageID": msg_id,
11046                            "type": "tool",
11047                            "callID": tc.id,
11048                            "tool": tc.function.name,
11049                            "state": state,
11050                        });
11051                        if let Some((result_position, _)) = paired_result {
11052                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11053                                serde_json::json!(result_position);
11054                        }
11055                        if paired_result.is_some_and(|(_, result)| {
11056                            crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11057                        }) {
11058                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11059                        }
11060                        if let Some((_, result)) = paired_result {
11061                            set_grok_message_extension(&mut part, self.meta.source, result);
11062                        }
11063                        parts.push(part);
11064                    }
11065                    let mut info = serde_json::json!({
11066                        "id": msg_id,
11067                        "sessionID": session_id,
11068                        "role": "assistant",
11069                        "time": {"created": timestamp},
11070                    });
11071                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11072                    opencode_restore_agent_model_fields(
11073                        &mut info, msg, /* is_assistant */ true,
11074                    );
11075                    set_grok_message_extension(&mut info, self.meta.source, msg);
11076                    out.push(serde_json::json!({
11077                        "info": info,
11078                        "parts": parts,
11079                    }));
11080                    i += 1;
11081                }
11082                // A Tool message is always folded into its call's assistant
11083                // `tool` part above (via occurrence-aware global pairing, not
11084                // positional adjacency), so it never needs its own entry
11085                // here — just advance past it.
11086                Role::Tool => i += 1,
11087            }
11088        }
11089        Ok(())
11090    }
11091
11092    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11093    /// `messages` (T3 cross-format/full synthesis tier — mirrors
11094    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11095    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11096    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11097    /// (§1.2 — the `opencode export`/`import` interchange shape).
11098    fn to_opencode_jsonl(&self) -> Result<String> {
11099        let mut info = self.synthesized_opencode_info();
11100        let ses_id = info
11101            .get("id")
11102            .and_then(Value::as_str)
11103            .unwrap_or("ses_new")
11104            .to_string();
11105        let mut messages_json: Vec<Value> = Vec::new();
11106        let mut counter: u64 = 0;
11107        let mut timestamp_cursor =
11108            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11109        self.append_synthesized_opencode_messages(
11110            &mut messages_json,
11111            &self.messages,
11112            &ses_id,
11113            &mut counter,
11114            &mut timestamp_cursor,
11115        )?;
11116        if !messages_json.is_empty() {
11117            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11118        }
11119        let doc = serde_json::json!({"info": info, "messages": messages_json});
11120        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11121    }
11122
11123    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11124    /// imported records **value-equal at their position** in the export
11125    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11126    /// via [`Self::opencode_records_from_raw`], never re-derived from the
11127    /// lossy canonical `messages` — then append freshly synthesized
11128    /// `{info, parts}` objects for the tail via
11129    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11130    /// line-oriented formats' splice, `out` here is a single export
11131    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11132    /// assertion accordingly: value-equality at position, not byte
11133    /// equality of a line range).
11134    fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11135        if self.raw.is_empty() {
11136            return self.to_opencode_jsonl();
11137        }
11138        let (session_info, records) = self.opencode_records_from_raw();
11139        let (_, message_prefix_len) = self.spliced_prefix_lens();
11140
11141        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11142        if let Some(id) = session_id {
11143            info["id"] = Value::String(id.to_string());
11144        }
11145        let ses_id_for_new = info
11146            .get("id")
11147            .and_then(Value::as_str)
11148            .unwrap_or("ses_new")
11149            .to_string();
11150
11151        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11152            .chain(records.iter().flat_map(|(msg, parts)| {
11153                std::iter::once(opencode_max_timestamp(msg))
11154                    .chain(parts.iter().map(opencode_max_timestamp))
11155            }))
11156            .flatten()
11157            .max()
11158            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11159
11160        let mut messages_json: Vec<Value> = records
11161            .into_iter()
11162            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11163            .collect();
11164        let imported_len = messages_json.len();
11165
11166        let mut counter: u64 = 0;
11167        self.append_synthesized_opencode_messages(
11168            &mut messages_json,
11169            &self.messages[message_prefix_len..],
11170            &ses_id_for_new,
11171            &mut counter,
11172            &mut timestamp_cursor,
11173        )?;
11174        if messages_json.len() > imported_len {
11175            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11176        }
11177
11178        let doc = serde_json::json!({"info": info, "messages": messages_json});
11179        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11180    }
11181
11182    /// The **required** direct-write fallback (S5): write the imported
11183    /// OpenCode records **verbatim** — excess/unknown keys, part-row
11184    /// timestamps, and `session_diff`/`todo` side-records intact — to a
11185    /// generation-B JSON-file storage tree
11186    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11187    /// `opencode import` cannot provide (S5: import re-decodes through a
11188    /// strict schema and STRIPS excess keys; inserts part rows without
11189    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11190    /// has no ingestion path for `session_diff`/`todo` at all).
11191    ///
11192    /// Writes the JSON-FILE layout rather than a live SQLite write
11193    /// specifically to avoid a new `rusqlite`-class dependency on this
11194    /// build's memory-constrained box (see the build report); `session_diff`
11195    /// itself is still JSON-written by upstream even on SQLite installs
11196    /// (§1.3), so this is a real fidelity path, not a fictional one.
11197    ///
11198    /// Returns the `storage/session/<projectID>/` directory written to.
11199    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11200        let (session_info, mut records) = self.opencode_records_from_raw();
11201        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11202        let ses_id = info
11203            .get("id")
11204            .and_then(Value::as_str)
11205            .unwrap_or("ses_new")
11206            .to_string();
11207        if info.get("id").is_none() {
11208            info["id"] = Value::String(ses_id.clone());
11209        }
11210        let project_id = info
11211            .get("projectID")
11212            .and_then(Value::as_str)
11213            .unwrap_or("global")
11214            .to_string();
11215
11216        // Appended tail (messages produced after import): synthesize fresh
11217        // message/part VALUES via the same T3 synthesis the splice writer
11218        // uses, so continuation turns get files too. Do this BEFORE creating
11219        // any directories: timestamp exhaustion must fail atomically rather
11220        // than leave a partial direct-write tree behind.
11221        let (_, message_prefix_len) = self.spliced_prefix_lens();
11222        let mut counter: u64 = 0;
11223        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11224            .chain(records.iter().flat_map(|(msg, parts)| {
11225                std::iter::once(opencode_max_timestamp(msg))
11226                    .chain(parts.iter().map(opencode_max_timestamp))
11227            }))
11228            .flatten()
11229            .max()
11230            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11231        let mut appended_json: Vec<Value> = Vec::new();
11232        self.append_synthesized_opencode_messages(
11233            &mut appended_json,
11234            &self.messages[message_prefix_len..],
11235            &ses_id,
11236            &mut counter,
11237            &mut timestamp_cursor,
11238        )?;
11239        if !appended_json.is_empty() {
11240            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11241        }
11242        for entry in appended_json {
11243            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11244            let parts = entry
11245                .get("parts")
11246                .and_then(Value::as_array)
11247                .cloned()
11248                .unwrap_or_default();
11249            records.push((msg, parts));
11250        }
11251
11252        let storage = data_root.join("storage");
11253        let session_dir = storage.join("session").join(&project_id);
11254        std::fs::create_dir_all(&session_dir)?;
11255        std::fs::write(
11256            session_dir.join(format!("{ses_id}.json")),
11257            serde_json::to_string_pretty(&info).unwrap_or_default(),
11258        )?;
11259
11260        let message_dir = storage.join("message").join(&ses_id);
11261        let part_dir = storage.join("part");
11262        std::fs::create_dir_all(&message_dir)?;
11263
11264        for (msg, parts) in &records {
11265            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11266                continue;
11267            };
11268            std::fs::write(
11269                message_dir.join(format!("{msg_id}.json")),
11270                serde_json::to_string_pretty(msg).unwrap_or_default(),
11271            )?;
11272            let this_part_dir = part_dir.join(msg_id);
11273            std::fs::create_dir_all(&this_part_dir)?;
11274            for part in parts {
11275                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11276                    continue;
11277                };
11278                std::fs::write(
11279                    this_part_dir.join(format!("{part_id}.json")),
11280                    serde_json::to_string_pretty(part).unwrap_or_default(),
11281                )?;
11282            }
11283        }
11284
11285        // Side-records (S5c): session_diff / todo have NO ingestion path via
11286        // `opencode import` at all — the direct write is their only
11287        // fidelity path.
11288        for header in &self.meta.opencode_headers {
11289            let Some(key) = header.get("key").and_then(Value::as_array) else {
11290                continue;
11291            };
11292            let Some(kind) = key.first().and_then(Value::as_str) else {
11293                continue;
11294            };
11295            let value = header.get("value").cloned().unwrap_or(Value::Null);
11296            if !matches!(kind, "session_diff" | "todo") {
11297                continue;
11298            }
11299            let dir = storage.join(kind);
11300            std::fs::create_dir_all(&dir)?;
11301            std::fs::write(
11302                dir.join(format!("{ses_id}.json")),
11303                serde_json::to_string_pretty(&value).unwrap_or_default(),
11304            )?;
11305        }
11306
11307        Ok(session_dir)
11308    }
11309}
11310
11311fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11312    *counter += 1;
11313    format!("{prefix}_synth{counter:06}")
11314}
11315
11316/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11317/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11318/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11319/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11320/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11321/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11322/// ONLY when its metadata key is present (a synthesized continuation turn, or
11323/// a User message that never carried `agent`, stays clean — no spurious
11324/// null/empty fields).
11325///
11326/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11327///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11328///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11329/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11330///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11331///   inverse must match per-role:
11332///   - User: `push_opencode_user` stores `metadata["model"]` as the
11333///     STRINGIFIED `{providerID, modelID, variant?}` object
11334///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11335///     as that same object under `"model"`.
11336///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11337///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11338///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
11339///     join; a `modelID` containing further `/`s round-trips correctly since
11340///     `split_once` only consumes the first) and re-emitted as the two
11341///     top-level `providerID`/`modelID` fields the loader actually reads.
11342/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11343///   fields exist on opencode's `User` schema) — `is_summary` re-expands
11344///   `"true"` back to the native `summary: true` bool (the loader only ever
11345///   sets the metadata key on `Some(true)`, never on absent/false, so the
11346///   inverse never needs to emit `false`); `finish` is a plain string;
11347///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11348///   `Value` (a number and an object respectively), so they're re-parsed
11349///   from that stringified form and re-emitted as the native JSON value —
11350///   NOT as strings — matching `msg_value.get(field)` shape exactly.
11351fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11352    if let Some(agent) = msg.metadata.get("agent") {
11353        info["agent"] = Value::String(agent.clone());
11354    }
11355    if let Some(model) = msg.metadata.get("model") {
11356        if is_assistant {
11357            if let Some((provider, model_id)) = model.split_once('/') {
11358                info["providerID"] = Value::String(provider.to_string());
11359                info["modelID"] = Value::String(model_id.to_string());
11360            }
11361        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11362            info["model"] = v;
11363        }
11364    }
11365    if !is_assistant {
11366        return;
11367    }
11368    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11369        info["summary"] = Value::Bool(true);
11370    }
11371    if let Some(finish) = msg.metadata.get("finish") {
11372        info["finish"] = Value::String(finish.clone());
11373    }
11374    if let Some(cost) = msg.metadata.get("cost") {
11375        if let Ok(v) = serde_json::from_str::<Value>(cost) {
11376            info["cost"] = v;
11377        }
11378    }
11379    if let Some(tokens) = msg.metadata.get("tokens") {
11380        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11381            info["tokens"] = v;
11382        }
11383    }
11384}
11385
11386fn opencode_user_parts_from_message(
11387    msg: &ChatMessage,
11388    msg_id: &str,
11389    session_id: &str,
11390    counter: &mut u64,
11391) -> Vec<Value> {
11392    let mut parts = Vec::new();
11393    if let Some(cps) = &msg.content_parts {
11394        for p in cps {
11395            match p.get("type").and_then(Value::as_str) {
11396                Some("text") => {
11397                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11398                        parts.push(serde_json::json!({
11399                            "id": opencode_fresh_id("prt", counter),
11400                            "sessionID": session_id,
11401                            "messageID": msg_id,
11402                            "type": "text",
11403                            "text": t,
11404                        }));
11405                    }
11406                }
11407                Some("image_url") => {
11408                    if let Some(url) = p
11409                        .get("image_url")
11410                        .and_then(|u| u.get("url"))
11411                        .and_then(Value::as_str)
11412                    {
11413                        let mime = url
11414                            .strip_prefix("data:")
11415                            .and_then(|r| r.split_once(','))
11416                            .map(|(m, _)| m.trim_end_matches(";base64"))
11417                            .unwrap_or("application/octet-stream");
11418                        parts.push(serde_json::json!({
11419                            "id": opencode_fresh_id("prt", counter),
11420                            "sessionID": session_id,
11421                            "messageID": msg_id,
11422                            "type": "file",
11423                            "mime": mime,
11424                            "url": url,
11425                        }));
11426                    }
11427                }
11428                _ => {}
11429            }
11430        }
11431    } else if let Some(t) = &msg.content {
11432        if !t.is_empty() {
11433            parts.push(serde_json::json!({
11434                "id": opencode_fresh_id("prt", counter),
11435                "sessionID": session_id,
11436                "messageID": msg_id,
11437                "type": "text",
11438                "text": t,
11439            }));
11440        }
11441    }
11442    parts
11443}
11444
11445fn codex_response_item(payload: Value, ts: &str) -> Value {
11446    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11447}
11448
11449/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11450/// see [`Session::write_codex_records`]); a no-op returning `payload`
11451/// untouched when `None`, so the historical byte shape is preserved for
11452/// every record that has no merge ambiguity to disambiguate.
11453fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11454    if let Some(tid) = turn_id {
11455        payload["metadata"] = serde_json::json!({"turn_id": tid});
11456    }
11457    payload
11458}
11459
11460/// Build a Codex `message` response_item's `content` block array from a
11461/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11462/// parse. When `content_parts` is `None` this MUST reproduce the historical
11463/// single-block shape exactly (IX-5's overriding constraint: a text-only
11464/// message's export stays byte-identical) — only a multimodal message gets
11465/// one `{text_type}` block per non-empty text part plus one native Codex
11466/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11467/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11468/// `output_text` blocks already follow the family of) per `image_url` part.
11469fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11470    match &msg.content_parts {
11471        Some(parts) => {
11472            let mut blocks = Vec::new();
11473            for p in parts {
11474                match p.get("type").and_then(Value::as_str) {
11475                    Some("text") => {
11476                        if let Some(t) = p.get("text").and_then(Value::as_str) {
11477                            if !t.is_empty() {
11478                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
11479                            }
11480                        }
11481                    }
11482                    Some("image_url") => {
11483                        if let Some(url) = p
11484                            .get("image_url")
11485                            .and_then(|u| u.get("url"))
11486                            .and_then(Value::as_str)
11487                        {
11488                            blocks.push(serde_json::json!({
11489                                "type": "input_image",
11490                                "image_url": url,
11491                            }));
11492                        }
11493                    }
11494                    _ => {}
11495                }
11496            }
11497            Value::Array(blocks)
11498        }
11499        None => {
11500            let text = msg.content.clone().unwrap_or_default();
11501            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11502        }
11503    }
11504}
11505
11506/// PARITY-11 (nested images, honest-residue side): a Codex
11507/// `function_call_output` response_item's `output` field is a BARE STRING
11508/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11509/// no structured content array, so [`codex_message_content_blocks`]'s
11510/// `input_image` slot genuinely does not apply here). A nested image captured
11511/// off a Claude `tool_result` (`extract_tool_result_content`,
11512/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11513/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11514/// from a real, intentional annotation and impossible to tell apart from
11515/// "the data survived") or dropping it with zero trace, fold in an honest,
11516/// countable disclosure of exactly how many images were dropped and why —
11517/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11518/// on the WRITE side instead of the read side. `content_parts` being `None`
11519/// (every pre-existing call site, and any tool result with no nested image)
11520/// reproduces the historical `msg.content` text byte-for-byte.
11521fn codex_tool_output_text(msg: &ChatMessage) -> String {
11522    let mut text = msg.content.clone().unwrap_or_default();
11523    if let Some(parts) = &msg.content_parts {
11524        let n = parts
11525            .iter()
11526            .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11527            .count();
11528        if n > 0 {
11529            if !text.is_empty() {
11530                text.push('\n');
11531            }
11532            text.push_str(&format!(
11533                "[image: {n} nested image(s) dropped — codex tool output has no \
11534                 structured content slot to carry them]"
11535            ));
11536        }
11537    }
11538    text
11539}
11540
11541// ---- Pi writer helpers -----------------------------------------------------
11542
11543/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11544/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11545/// file in place on first resume (`pi-fields.md` sm:848-850).
11546fn push_pi_header(
11547    out: &mut String,
11548    id: &str,
11549    cwd: &str,
11550    parent_session: Option<&str>,
11551    created_at: Option<&str>,
11552    claude_fork_context_ref: Option<&str>,
11553) {
11554    let mut header = serde_json::json!({
11555        "type": "session",
11556        "version": 3,
11557        "id": id,
11558        "timestamp": created_at.unwrap_or(SYNTH_TS),
11559        "cwd": cwd,
11560    });
11561    if let Some(ps) = parent_session {
11562        header["parentSession"] = Value::String(ps.to_string());
11563    }
11564    // D7: namespaced passthrough field, exactly like the Codex writer's
11565    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11566    // header keys, and `capture_pi_header` reads this same key back on
11567    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11568    // fork-context-ref record instead of silently losing it on this hop.
11569    if let Some(raw) = claude_fork_context_ref {
11570        header["claude_fork_context_ref"] =
11571            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11572    }
11573    push_jsonl(out, &header);
11574}
11575
11576/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11577/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11578/// deterministic here rather than random, which still satisfies "fresh,
11579/// collision-free" without an extra RNG dependency).
11580fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11581    loop {
11582        *counter += 1;
11583        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11584        let id = format!("{:08x}", (h >> 32) as u32);
11585        if used.insert(id.clone()) {
11586            return id;
11587        }
11588    }
11589}
11590
11591/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11592/// inverse of the loader's `data:{mime};base64,{data}` construction.
11593fn parse_data_uri(url: &str) -> Option<(String, String)> {
11594    let rest = url.strip_prefix("data:")?;
11595    let (meta, data) = rest.split_once(',')?;
11596    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11597    Some((mime.to_string(), data.to_string()))
11598}
11599
11600/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11601/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11602/// `toolResult` entries (both use the identical union on the wire).
11603fn pi_content_value(msg: &ChatMessage) -> Value {
11604    if let Some(parts) = &msg.content_parts {
11605        let mut arr = Vec::new();
11606        for p in parts {
11607            match p.get("type").and_then(Value::as_str) {
11608                Some("text") => {
11609                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11610                        arr.push(serde_json::json!({"type": "text", "text": t}));
11611                    }
11612                }
11613                Some("image_url") => {
11614                    if let Some(url) = p
11615                        .get("image_url")
11616                        .and_then(|u| u.get("url"))
11617                        .and_then(Value::as_str)
11618                    {
11619                        if let Some((mime, data)) = parse_data_uri(url) {
11620                            arr.push(
11621                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11622                            );
11623                        }
11624                    }
11625                }
11626                _ => {}
11627            }
11628        }
11629        Value::Array(arr)
11630    } else {
11631        Value::String(msg.content.clone().unwrap_or_default())
11632    }
11633}
11634
11635fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11636    let mut arr = Vec::new();
11637    if let Some(thinking) = msg.metadata.get("thinking") {
11638        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11639        if let Some(sig) = msg.metadata.get("thinking_signature") {
11640            block["thinkingSignature"] = Value::String(sig.clone());
11641        }
11642        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11643            block["redacted"] = Value::Bool(true);
11644        }
11645        arr.push(block);
11646    }
11647    if let Some(text) = &msg.content {
11648        if !text.is_empty() {
11649            let mut block = serde_json::json!({"type": "text", "text": text});
11650            if let Some(sig) = msg.metadata.get("pi_text_signature") {
11651                block["textSignature"] = Value::String(sig.clone());
11652            }
11653            arr.push(block);
11654        }
11655    }
11656    for tc in msg.tool_calls() {
11657        let args = tc
11658            .function
11659            .parsed_arguments()
11660            .unwrap_or_else(|_| Value::Object(Default::default()));
11661        let mut block = serde_json::json!({
11662            "type": "toolCall",
11663            "id": tc.id,
11664            "name": tc.function.name,
11665            "arguments": args,
11666        });
11667        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11668            block["thoughtSignature"] = Value::String(sig.clone());
11669        }
11670        arr.push(block);
11671    }
11672    Value::Array(arr)
11673}
11674
11675fn default_pi_usage() -> Value {
11676    serde_json::json!({
11677        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11678        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11679    })
11680}
11681
11682fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11683    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11684}
11685
11686#[cfg(test)]
11687mod tests {
11688    use super::{opencode_message_timestamp, parent_tool_use_index, Session, SessionFormat};
11689    use crate::message::ChatMessage;
11690
11691    #[test]
11692    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
11693        let base = Session::from_native_messages(Vec::new());
11694        let mut native = base.to_native_jsonl_v2(&[]);
11695        native.push_str("{\"supercode_turn\":1}\n");
11696
11697        let parsed = Session::from_native_str(&native).unwrap();
11698        assert_eq!(parsed.parse_error_lines, 1);
11699        assert!(parsed.messages.is_empty());
11700        assert_eq!(
11701            parsed.raw.last().map(String::as_str),
11702            Some("{\"supercode_turn\":1}")
11703        );
11704    }
11705
11706    #[test]
11707    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
11708        let imported = Session::from_claude_code_str(
11709            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
11710        )
11711        .unwrap();
11712        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
11713        native.push_str("{\"supercode_turn\":1}\n");
11714
11715        let parsed = Session::from_native_str(&native).unwrap();
11716        let error = parsed
11717            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
11718            .unwrap_err();
11719        assert!(error.to_string().contains("parse loss"), "{error}");
11720    }
11721
11722    #[test]
11723    fn sidecar_loader_requires_a_supported_native_header() {
11724        for malformed in [
11725            "",
11726            "not-json\n",
11727            "{}\n",
11728            "{\"supercode_native\":2}\n",
11729            "{\"supercode_native\":99,\"source\":\"native\"}\n",
11730        ] {
11731            let error = Session::from_sidecar_str(malformed).unwrap_err();
11732            assert!(error.to_string().contains("sidecar header"), "{error}");
11733        }
11734    }
11735
11736    #[test]
11737    fn gemini_user_parts_preserve_text_media_and_response_order() {
11738        let session = Session::from_gemini_str(
11739            r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
11740{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
11741{"type":"user","content":[{"text":"before"},{"functionResponse":{"id":"b","name":"read","response":{"output":"B"}}},{"inlineData":{"mimeType":"image/png","data":"YQ=="}},{"functionResponse":{"name":"read","response":{"output":"A"}}},{"text":"after"}]}
11742"#,
11743        )
11744        .unwrap();
11745
11746        assert_eq!(session.messages.len(), 6);
11747        assert_eq!(
11748            session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
11749            "before"
11750        );
11751        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
11752        assert!(
11753            session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
11754                .as_str()
11755                .unwrap()
11756                .starts_with("data:image/png;base64,")
11757        );
11758        assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
11759        assert_eq!(
11760            session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
11761            "after"
11762        );
11763    }
11764
11765    #[test]
11766    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
11767        let msg = ChatMessage::user("continuation");
11768        let mut cursor = i64::MAX - 1;
11769        assert_eq!(
11770            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
11771            i64::MAX
11772        );
11773        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
11774        assert!(err.to_string().contains("after i64::MAX"));
11775        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
11776    }
11777
11778    /// Pin of the single-pass indexer against the relevant Claude tool-result
11779    /// shape (SUP-21). An id absent from the transcript must map to nothing.
11780    #[test]
11781    fn parent_tool_use_index_matches_known_fixture_linkage() {
11782        let main_text = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SYjhg9qRCzUWY2GTa3iazQ","type":"tool_result","content":[{"type":"text","text":"agentId: ad8dc6cf98b49eea6"}]}]},"toolUseResult":{"agentId":"ad8dc6cf98b49eea6"}}"#;
11783
11784        let ids = vec![
11785            "ad8dc6cf98b49eea6".to_string(),
11786            "no-such-agent-id".to_string(),
11787        ];
11788        let index = parent_tool_use_index(main_text, &ids);
11789
11790        assert_eq!(
11791            index.get("ad8dc6cf98b49eea6").map(String::as_str),
11792            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
11793            "known agent id must resolve to the pinned parent tool_use_id"
11794        );
11795        assert_eq!(
11796            index.get("no-such-agent-id"),
11797            None,
11798            "unknown agent id must yield no entry (best-effort None)"
11799        );
11800    }
11801
11802    #[test]
11803    fn parent_tool_use_index_empty_ids_returns_empty_map() {
11804        let index = parent_tool_use_index("irrelevant text", &[]);
11805        assert!(index.is_empty());
11806    }
11807}