Skip to main content

supercode_interchange/session/
pi.rs

1//! Pi session codec: loaders, writers and native-record helpers.
2
3use super::*;
4
5impl Session {
6    /// Load a Pi session from a file.
7    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
8        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
9    }
10
11    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
12    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
13    ///
14    /// Line 1 is the `session` header; every other line is one `SessionEntry`
15    /// in a tree keyed by `id`/`parentId` — file order is append order, not
16    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
17    /// exactly like Claude Code/Codex). `messages` is the **active path
18    /// only**: pi's own leaf rule is "the last entry in file order"
19    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
20    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
21    /// state records (`thinking_level_change`/`model_change`/`custom`/
22    /// `session_info`) are never visited by that walk — they survive in
23    /// `raw` only, pi's defining residue (§1.1).
24    ///
25    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
26    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
27    /// `custom`) produces no canonical message — raw-only survival, never a
28    /// panic — and the Pi corpus audit turns that into a
29    /// visible coverage failure rather than a silent drop.
30    ///
31    /// Same fail-loud discipline applies to `ImageContent` blocks
32    /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
33    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
34    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
35    /// cites the containing union) — a follow-up TR tracks confirming it
36    /// against a real corpus. Until then, an image block that doesn't match
37    /// that shape never gets silently synthesized as an empty/corrupt
38    /// `image_url` part; the containing message survives in `raw` only and
39    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
40    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
41        Self::from_pi_v3_dialect(jsonl, SessionSource::Pi, false)
42    }
43
44    pub(super) fn from_pi_v3_dialect(
45        jsonl: &str,
46        source: SessionSource,
47        openclaw: bool,
48    ) -> Result<Session> {
49        let mut meta = SessionMeta::new(source);
50        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
51        // blank-skipping PARSE walk (`lines_v`) below, which must keep
52        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
53        // records (a blank line is never a record, on either view).
54        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
55        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
56        let non_empty_line_count = non_empty_lines(jsonl).count();
57        let lines_v: Vec<Value> = non_empty_lines(jsonl)
58            .filter_map(|l| serde_json::from_str(l).ok())
59            .collect();
60        // PARITY-15: every line that failed to even deserialize as JSON at
61        // all (never mind whether it then parsed as a recognized
62        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
63        // counter.
64        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
65
66        if let Some(header) = lines_v.first() {
67            capture_pi_header(header, &mut meta)?;
68            if openclaw {
69                openclaw_capture_header_nouns(header, &mut meta);
70            }
71        }
72
73        // Every non-header entry that parses as an object carrying an `id`.
74        // (A line that fails to parse, or a header re-parsed as an entry,
75        // simply never enters `by_id` — it survives in `raw` only, exactly
76        // like a malformed/non-conversational line in the other loaders.)
77        struct PiEntry {
78            id: String,
79            parent_id: Option<String>,
80            value: Value,
81        }
82        let mut entries: Vec<PiEntry> = Vec::new();
83        let mut by_id: HashMap<String, usize> = HashMap::new();
84        for v in lines_v.iter().skip(1) {
85            let Some(id) = v.get("id").and_then(Value::as_str) else {
86                continue;
87            };
88            let parent_id = v
89                .get("parentId")
90                .and_then(Value::as_str)
91                .map(str::to_string);
92            by_id.insert(id.to_string(), entries.len());
93            entries.push(PiEntry {
94                id: id.to_string(),
95                parent_id,
96                value: v.clone(),
97            });
98        }
99
100        if entries.is_empty() {
101            return Ok(Session {
102                meta,
103                messages: Vec::new(),
104                subagents: Vec::new(),
105                raw,
106                raw_trailing_newline,
107                imported_message_count: Some(0),
108                // Pi is line-oriented: `raw` is split directly out of the
109                // source text (strict-verbatim, IX-1), even for this
110                // no-entries early return.
111                raw_is_verbatim: true,
112                parse_error_lines,
113                load_residue: Vec::new(),
114            });
115        }
116
117        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
118        // necessarily a `message` entry — a trailing `label`/`session_info`
119        // still anchors the walk correctly since the walk just follows
120        // `parentId` regardless of the leaf's own type.
121        //
122        // OpenClaw dialect: a `type:"leaf"` entry REDIRECTS the anchor to its
123        // `targetId` (last one wins); with none — or a dangling/null target —
124        // the default rule applies, skipping trailing `leaf` entries
125        // themselves and `appendMode:"side"` entries, which never anchor.
126        let default_leaf_idx = if openclaw {
127            entries
128                .iter()
129                .rposition(|entry| {
130                    entry.value.get("type").and_then(Value::as_str) != Some("leaf")
131                        && entry.value.get("appendMode").and_then(Value::as_str) != Some("side")
132                })
133                .unwrap_or(entries.len() - 1)
134        } else {
135            entries.len() - 1
136        };
137        let leaf_idx = if openclaw {
138            entries
139                .iter()
140                .rev()
141                .find(|entry| entry.value.get("type").and_then(Value::as_str) == Some("leaf"))
142                .and_then(|redirect| {
143                    redirect
144                        .value
145                        .get("targetId")
146                        .and_then(Value::as_str)
147                        .and_then(|target| by_id.get(target).copied())
148                })
149                .unwrap_or(default_leaf_idx)
150        } else {
151            default_leaf_idx
152        };
153        let mut chain_rev: Vec<usize> = Vec::new();
154        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
155        let mut guard = 0usize;
156        while let Some(id) = cur {
157            let Some(&idx) = by_id.get(&id) else { break };
158            chain_rev.push(idx);
159            cur = entries[idx].parent_id.clone();
160            guard += 1;
161            if guard > entries.len() + 1 {
162                break; // cycle guard — malformed parentId chain
163            }
164        }
165        chain_rev.reverse();
166        let active = chain_rev; // indices into `entries`, root..leaf order
167
168        let pos_in_active: HashMap<&str, usize> = active
169            .iter()
170            .enumerate()
171            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
172            .collect();
173
174        // First pass: compaction discipline (§2.1 S3) — every message from an
175        // entry before the LATEST `firstKeptEntryId` on the active path is
176        // excluded from replay (`compacted_out`), mirroring pi's own
177        // `buildContextEntries` slice (`sm:414-450`).
178        let mut kept_from_pos = 0usize;
179        for &idx in &active {
180            let e = &entries[idx];
181            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
182                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
183                    if let Some(&p) = pos_in_active.get(fk) {
184                        kept_from_pos = kept_from_pos.max(p);
185                    }
186                }
187            }
188        }
189
190        let mut messages = Vec::new();
191        let mut current_model: Option<String> = None;
192        for (pos, &idx) in active.iter().enumerate() {
193            let e = &entries[idx];
194            let v = &e.value;
195            let entry_ts = v
196                .get("timestamp")
197                .and_then(Value::as_str)
198                .map(str::to_string);
199            let before = messages.len();
200            match v.get("type").and_then(Value::as_str) {
201                Some("message") => {
202                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
203                    match msg_v.get("role").and_then(Value::as_str) {
204                        Some("user") => push_pi_user(&msg_v, &mut messages),
205                        Some("assistant") => {
206                            push_pi_assistant(&msg_v, &mut messages);
207                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
208                                current_model = Some(m.to_string());
209                            }
210                        }
211                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
212                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
213                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
214                        // OPEN UNION (S6): any other role — raw-only survival.
215                        _ => {}
216                    }
217                }
218                Some("custom_message") => push_pi_custom_common(v, &mut messages),
219                Some("compaction") => push_pi_compaction(v, &mut messages),
220                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
221                Some("model_change") => {
222                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
223                        current_model = Some(m.to_string());
224                    }
225                }
226                Some("session_info") => {
227                    if let Some(name) = v.get("name").and_then(Value::as_str) {
228                        if !name.is_empty() {
229                            meta.lineage
230                                .insert("session_name".to_string(), name.to_string());
231                        }
232                    }
233                }
234                // thinking_level_change, custom (entry-level state), label —
235                // no clean home, raw-only (§2.3).
236                _ => {}
237            }
238            let is_summary = matches!(
239                v.get("type").and_then(Value::as_str),
240                Some("compaction") | Some("branch_summary")
241            );
242            for m in &mut messages[before..] {
243                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
244                if openclaw {
245                    // Vendor metadata (`message.__openclaw.*`) — preserved as
246                    // provenance, never interpreted. Session-key/delegate
247                    // references inside it stay inert strings (mirror, not
248                    // recursion).
249                    if let Some(vendor) = v
250                        .get("message")
251                        .and_then(|mm| mm.get("__openclaw"))
252                        .and_then(Value::as_object)
253                    {
254                        for (key, value) in vendor {
255                            let rendered = match value {
256                                Value::String(text) => text.clone(),
257                                other => other.to_string(),
258                            };
259                            m.metadata.insert(format!("openclaw_{key}"), rendered);
260                        }
261                    }
262                }
263                if let Some(p) = &e.parent_id {
264                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
265                }
266                if let Some(ts) = &entry_ts {
267                    m.metadata
268                        .entry("timestamp".to_string())
269                        .or_insert_with(|| ts.clone());
270                }
271                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
272                // is pi's authoritative, always-monotonic-in-file-order
273                // wall-clock (mandatory on every entry) and wins whenever
274                // present. The nested `message.timestamp` (unix-ms) is only
275                // reached here — via `entry(...).or_insert_with`, so it
276                // never overwrites the entry-level value — in the rare case
277                // an entry lacks its own `timestamp`. This intentionally
278                // does NOT prefer the msg-level field even though it LOOKS
279                // more precise: unlike the entry-level timestamp, it is not
280                // guaranteed monotonic with this loader's root->leaf
281                // linearization (e.g. a rewound-branch entry can carry an
282                // earlier msg-level clock reading than its file-order
283                // neighbors), and OpenCode's own loader re-sorts messages by
284                // this canonical timestamp — a non-monotonic source would
285                // silently scramble replay order on a pi->opencode hop.
286                if let Some(ms) = v
287                    .get("message")
288                    .and_then(|mm| mm.get("timestamp"))
289                    .and_then(Value::as_u64)
290                {
291                    m.metadata
292                        .entry("timestamp".to_string())
293                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
294                }
295                // A compaction/branch-summary message IS the retained marker
296                // — never mark it excluded, regardless of its own position.
297                if !is_summary && pos < kept_from_pos {
298                    m.metadata
299                        .insert("compacted_out".to_string(), "true".to_string());
300                }
301            }
302            restore_single_grok_message(v, &mut messages[before..]);
303            for message in &mut messages[before..] {
304                restore_tool_outcome_extension(v, message);
305            }
306        }
307
308        meta.model = current_model;
309        ensure_tool_results_paired(&mut messages);
310        let imported_message_count = Some(messages.len());
311        Ok(Session {
312            meta,
313            messages,
314            subagents: Vec::new(),
315            raw,
316            raw_trailing_newline,
317            imported_message_count,
318            // Pi is line-oriented: `raw` is split directly out of the
319            // source text (strict-verbatim, IX-1).
320            raw_is_verbatim: true,
321            parse_error_lines,
322            load_residue: Vec::new(),
323        })
324    }
325}
326
327// ---- Pi ---------------------------------------------------------------
328
329fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
330    restore_codex_provenance_from_top_level(v, meta)?;
331    if let Some(id) = v.get("id").and_then(Value::as_str) {
332        meta.session_id = Some(id.to_string());
333    }
334    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
335        meta.cwd = Some(PathBuf::from(cwd));
336    }
337    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
338    let version = v
339        .get("version")
340        .and_then(Value::as_u64)
341        .map(|n| n.to_string())
342        .unwrap_or_else(|| "1".to_string());
343    meta.lineage.insert("pi_version".to_string(), version);
344    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
345        meta.lineage
346            .insert("created_at".to_string(), ts.to_string());
347    }
348    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
349        meta.lineage
350            .insert("parent_session_path".to_string(), ps.to_string());
351    }
352    // D7: the other half of `push_pi_header`'s passthrough — restores a
353    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
354    // trip reconstructs the original record (mirrors
355    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
356    // restore for the Codex hop).
357    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
358        if let Some(v) = v.get("claude_fork_context_ref") {
359            meta.lineage
360                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
361        }
362    }
363    Ok(())
364}
365
366/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
367/// `(mime, data)` when it looks like a real image payload.
368///
369/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
370/// `ai:316-350` for the `ImageContent` content-block union but does not
371/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
372/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
373/// Anthropic multimodal wire shape) is this loader's best guess, not a
374/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
375/// against a real pi corpus. Until then this function VALIDATES rather than
376/// assumes: both fields must be present, non-empty strings, and `data` must
377/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
378/// else is an unknown/unexpected image shape, and the caller must route the
379/// whole message to raw-only survival (S6-style fail loud) instead of
380/// silently synthesizing a corrupt/empty `image_url` part.
381fn pi_image_shape(item: &Value) -> Option<(String, String)> {
382    let mime = item.get("mimeType").and_then(Value::as_str)?;
383    let data = item.get("data").and_then(Value::as_str)?;
384    if mime.is_empty() || data.is_empty() {
385        return None;
386    }
387    if !data
388        .bytes()
389        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
390    {
391        return None;
392    }
393    Some((mime.to_string(), data.to_string()))
394}
395
396/// True if `content` (a pi content value: bare string or
397/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
398/// that does not match [`pi_image_shape`] — shared by the loader (which
399/// routes such a message to raw-only survival, never a synthesized-empty
400/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
401/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
402/// mismatch surfaces as a coverage FAILURE rather than vanishing.
403#[doc(hidden)]
404pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
405    let Some(Value::Array(items)) = content else {
406        return false;
407    };
408    items.iter().any(|item| {
409        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
410    })
411}
412
413/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
414/// into concatenated text plus, when a WELL-FORMED image block is present,
415/// the full `content_parts` array (leading text block + one `image_url` part
416/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
417/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
418/// the identical union (`pi-fields.md` §3a/§3c/§3e).
419///
420/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
421/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
422/// value that isn't recognizable base64), this NEVER synthesizes an empty/
423/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
424/// every caller must treat that as raw-only survival for the whole message
425/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
426/// guessed wrong fails loud instead of silently dropping/corrupting the
427/// image.
428fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
429    match content {
430        Some(Value::String(s)) => (s.clone(), None, false),
431        Some(Value::Array(items)) => {
432            let mut text = String::new();
433            let mut parts: Vec<Value> = Vec::new();
434            let mut has_image = false;
435            let mut unknown_image_shape = false;
436            for item in items {
437                match item.get("type").and_then(Value::as_str) {
438                    Some("text") => {
439                        if let Some(t) = item.get("text").and_then(Value::as_str) {
440                            push_str_field(&mut text, t);
441                        }
442                    }
443                    Some("image") => {
444                        has_image = true;
445                        match pi_image_shape(item) {
446                            Some((mime, data)) => {
447                                parts.push(serde_json::json!({
448                                    "type": "image_url",
449                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
450                                }));
451                            }
452                            None => unknown_image_shape = true,
453                        }
454                    }
455                    _ => {}
456                }
457            }
458            if unknown_image_shape {
459                // Never synthesize an empty/corrupt part for a shape we
460                // don't recognize — raw-only survival for the whole message;
461                // the coverage guard is what turns this into a visible
462                // failure (S6-style).
463                return (String::new(), None, true);
464            }
465            if has_image {
466                if !text.trim().is_empty() {
467                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
468                }
469                (text, Some(parts), false)
470            } else {
471                (text, None, false)
472            }
473        }
474        _ => (String::new(), None, false),
475    }
476}
477
478fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
479    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
480    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
481    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
482    // `message/UnknownImageShape` bucket is what turns this into a visible
483    // coverage failure.
484    if unknown_image_shape {
485        return;
486    }
487    if text.trim().is_empty() && parts.is_none() {
488        return;
489    }
490    let mut msg = match parts {
491        Some(parts) => ChatMessage {
492            role: Role::User,
493            content: None,
494            content_parts: Some(parts),
495            tool_calls: None,
496            tool_call_id: None,
497            name: None,
498            metadata: Default::default(),
499        },
500        None => ChatMessage::user(text),
501    };
502    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
503    // (`message.timestamp`) is a DISTINCT field from the canonical
504    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
505    // carry genuinely different values in real corpora (the fixture's are
506    // ~6 months apart). Preserve it separately so it isn't silently lost for
507    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
508    // native round-trip consumer) and the INHERENT residue note on
509    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
510    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
511        msg.metadata
512            .insert("pi_msg_timestamp".to_string(), ts.to_string());
513    }
514    out.push(msg);
515}
516
517fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
518    let mut text = String::new();
519    let mut calls: Vec<ToolCall> = Vec::new();
520    let mut thinking = String::new();
521    let mut thinking_seen = false;
522    let mut thinking_sig: Option<String> = None;
523    let mut thinking_redacted = false;
524    let mut text_sig: Option<String> = None;
525    let mut thought_sig: Option<String> = None;
526
527    if let Some(Value::Array(blocks)) = msg_v.get("content") {
528        for b in blocks {
529            match b.get("type").and_then(Value::as_str) {
530                Some("text") => {
531                    if let Some(t) = b.get("text").and_then(Value::as_str) {
532                        push_str_field(&mut text, t);
533                    }
534                    if let Some(sig) = b.get("textSignature") {
535                        text_sig = Some(match sig {
536                            Value::String(s) => s.clone(),
537                            other => other.to_string(),
538                        });
539                    }
540                }
541                Some("thinking") => {
542                    thinking_seen = true;
543                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
544                        push_str_field(&mut thinking, t);
545                    }
546                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
547                        thinking_sig = Some(sig.to_string());
548                    }
549                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
550                        thinking_redacted = true;
551                    }
552                }
553                Some("toolCall") => {
554                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
555                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
556                    // `arguments` is a JSON OBJECT on pi's wire, not a string
557                    // (`pi-fields.md` §3b open question 4) — serialize to the
558                    // string `FunctionCall::arguments` expects.
559                    let args = b
560                        .get("arguments")
561                        .cloned()
562                        .unwrap_or_else(|| Value::Object(Default::default()));
563                    calls.push(function_call(id, name, args.to_string()));
564                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
565                        thought_sig = Some(sig.to_string());
566                    }
567                }
568                _ => {}
569            }
570        }
571    }
572
573    let before = out.len();
574    push_assistant(out, text, calls);
575    // A recognized native assistant entry remains transcript state even
576    // when its content array is empty, except Pi's explicit empty error
577    // response: that record has no replayable content and is established
578    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
579    // non-error turns and Pi's standalone thinking-block shape.
580    let is_empty_error =
581        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
582    if out.len() == before && !is_empty_error {
583        let mut empty = ChatMessage {
584            role: Role::Assistant,
585            content: None,
586            content_parts: None,
587            tool_calls: None,
588            tool_call_id: None,
589            name: None,
590            metadata: Default::default(),
591        };
592        if !thinking_seen {
593            empty
594                .metadata
595                .insert("empty_assistant_record".to_string(), "true".to_string());
596        }
597        out.push(empty);
598    }
599    if out.len() > before {
600        let msg = out.last_mut().expect("just pushed");
601        if thinking_seen {
602            msg.metadata.insert("thinking".to_string(), thinking);
603        }
604        if let Some(s) = thinking_sig {
605            msg.metadata.insert("thinking_signature".to_string(), s);
606        }
607        if thinking_redacted {
608            msg.metadata
609                .insert("pi_thinking_redacted".to_string(), "true".to_string());
610        }
611        if let Some(s) = text_sig {
612            msg.metadata.insert("pi_text_signature".to_string(), s);
613        }
614        if let Some(s) = thought_sig {
615            msg.metadata.insert("pi_thought_signature".to_string(), s);
616        }
617        for (key, field) in [
618            ("pi_api", "api"),
619            ("pi_provider", "provider"),
620            ("pi_response_model", "responseModel"),
621            ("pi_response_id", "responseId"),
622            ("pi_stop_reason", "stopReason"),
623            ("pi_error_message", "errorMessage"),
624        ] {
625            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
626                msg.metadata.insert(key.to_string(), s.to_string());
627            }
628        }
629        if let Some(diag) = msg_v.get("diagnostics") {
630            if !diag.is_null() {
631                msg.metadata
632                    .insert("pi_diagnostics".to_string(), diag.to_string());
633            }
634        }
635        if let Some(usage) = msg_v.get("usage") {
636            if !usage.is_null() {
637                msg.metadata
638                    .insert("pi_usage".to_string(), usage.to_string());
639            }
640        }
641        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
642        // separately from the canonical entry-level ISO `timestamp` — see
643        // `push_pi_user`.
644        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
645            msg.metadata
646                .insert("pi_msg_timestamp".to_string(), ts.to_string());
647        }
648    }
649}
650
651fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
652    let id = msg_v
653        .get("toolCallId")
654        .and_then(Value::as_str)
655        .unwrap_or_default();
656    let name = msg_v
657        .get("toolName")
658        .and_then(Value::as_str)
659        .unwrap_or_default();
660    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
661    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
662    // survival, never a synthesized-empty part. Dropping the toolResult
663    // message here leaves its `toolCallId` unanswered, which
664    // `ensure_tool_results_paired` already turns into a visible
665    // "[no tool result recorded — turn interrupted]" placeholder — a loud
666    // failure mode, not a silent one.
667    if unknown_image_shape {
668        return;
669    }
670    let mut msg = ChatMessage {
671        role: Role::Tool,
672        content: Some(text),
673        content_parts: parts,
674        tool_calls: None,
675        tool_call_id: Some(id.to_string()),
676        name: Some(name.to_string()),
677        metadata: Default::default(),
678    };
679    if let Some(details) = msg_v.get("details") {
680        if !details.is_null() {
681            msg.metadata
682                .insert("pi_tool_details".to_string(), details.to_string());
683        }
684    }
685    let is_error = msg_v
686        .get("isError")
687        .and_then(Value::as_bool)
688        .unwrap_or(false);
689    msg.metadata
690        .insert("pi_is_error".to_string(), is_error.to_string());
691    if is_error {
692        crate::mark_tool_error(&mut msg);
693    }
694    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
695    // separately from the canonical entry-level ISO `timestamp` — see
696    // `push_pi_user`.
697    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
698        msg.metadata
699            .insert("pi_msg_timestamp".to_string(), ts.to_string());
700    }
701    out.push(msg);
702}
703
704/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
705/// pi itself sends the model, mirroring `bashExecutionToText`
706/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
707/// aren't reproduced in the frozen research doc (only cited by file:line),
708/// so this is a faithful, clearly-labeled reconstruction — every structured
709/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
710fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
711    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
712    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
713    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
714    let cancelled = msg_v
715        .get("cancelled")
716        .and_then(Value::as_bool)
717        .unwrap_or(false);
718    let truncated = msg_v
719        .get("truncated")
720        .and_then(Value::as_bool)
721        .unwrap_or(false);
722
723    let mut text = format!("$ {command}\n{output}");
724    if let Some(code) = exit_code {
725        if code != 0 {
726            text.push_str(&format!("\n[exit code: {code}]"));
727        }
728    }
729    if cancelled {
730        text.push_str("\n[cancelled]");
731    }
732    if truncated {
733        text.push_str("\n[truncated]");
734    }
735
736    let mut msg = ChatMessage::user(text);
737    msg.metadata
738        .insert("pi_bash_command".to_string(), command.to_string());
739    msg.metadata
740        .insert("pi_bash_output".to_string(), output.to_string());
741    if let Some(code) = exit_code {
742        msg.metadata
743            .insert("pi_bash_exit_code".to_string(), code.to_string());
744    }
745    msg.metadata
746        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
747    msg.metadata
748        .insert("pi_bash_truncated".to_string(), truncated.to_string());
749    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
750        msg.metadata
751            .insert("pi_bash_full_output_path".to_string(), p.to_string());
752    }
753    // `!!` — hidden from the model context; honored by `is_replay_excluded`
754    // on every writer, not just pi's own (§2.2).
755    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
756        msg.metadata
757            .insert("pi_exclude_from_context".to_string(), "true".to_string());
758    }
759    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
760    // separately from the canonical entry-level ISO `timestamp` — see
761    // `push_pi_user`.
762    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
763        msg.metadata
764            .insert("pi_msg_timestamp".to_string(), ts.to_string());
765    }
766    out.push(msg);
767}
768
769/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
770/// stamps on a re-materialized content-bearing Claude `system` record (see
771/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
772/// never collide with a real pi `CustomMessage.customType` — pi's own
773/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
774/// migration targets), never this literal string.
775const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
776
777/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
778/// `custom_message` entries (§9) — both enter context as a `User` message
779/// with the same `customType`/`display`/`details` residue.
780///
781/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
782/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
783/// actually a re-materialized content-bearing Claude `system` record round-
784/// tripping through pi, not a genuine pi extension message — restore
785/// `Role::System` + `metadata["systemSubtype"]` (from `details.
786/// claude_system_subtype`, falling back to `local_command` — still one of
787/// `push_claude_system`'s own keep subtypes — exactly like
788/// `write_codex_records`'s Codex-leg fallback) instead of the generic
789/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
790/// the exact original role, not just the text.
791fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
792    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
793        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
794        if content.trim().is_empty() {
795            return;
796        }
797        let subtype = v
798            .get("details")
799            .and_then(|d| d.get("claude_system_subtype"))
800            .and_then(Value::as_str)
801            .unwrap_or("local_command");
802        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
803        return;
804    }
805    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
806    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
807    // survival, never a synthesized-empty part.
808    if unknown_image_shape {
809        return;
810    }
811    if text.trim().is_empty() && parts.is_none() {
812        return;
813    }
814    let mut msg = match parts {
815        Some(parts) => ChatMessage {
816            role: Role::User,
817            content: None,
818            content_parts: Some(parts),
819            tool_calls: None,
820            tool_call_id: None,
821            name: None,
822            metadata: Default::default(),
823        },
824        None => ChatMessage::user(text),
825    };
826    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
827        msg.metadata
828            .insert("pi_custom_type".to_string(), ct.to_string());
829    }
830    if let Some(d) = v.get("display").and_then(Value::as_bool) {
831        msg.metadata.insert("pi_display".to_string(), d.to_string());
832    }
833    if let Some(details) = v.get("details") {
834        if !details.is_null() {
835            msg.metadata
836                .insert("pi_details".to_string(), details.to_string());
837        }
838    }
839    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
840    // separately from the canonical entry-level ISO `timestamp` — see
841    // `push_pi_user`. `v` here is the `message` object for the `role:
842    // "custom"` case; for the top-level `custom_message` case `v` is the
843    // entry itself, whose `timestamp` is the entry-level ISO string (not a
844    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
845    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
846        msg.metadata
847            .insert("pi_msg_timestamp".to_string(), ts.to_string());
848    }
849    out.push(msg);
850}
851
852/// pi's own prefix-wrapped user text for a `compaction` entry summary
853/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
854/// The exact upstream wrapper string is cited (`msg:11-17`) but not
855/// reproduced in the frozen research doc; this is a clearly-labeled
856/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
857fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
858    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
859    if summary.trim().is_empty() {
860        return;
861    }
862    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
863    msg.metadata
864        .insert("pi_type".to_string(), "compaction".to_string());
865    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
866        msg.metadata
867            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
868    }
869    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
870        msg.metadata
871            .insert("pi_tokens_before".to_string(), tb.to_string());
872    }
873    if let Some(d) = entry_v.get("details") {
874        if !d.is_null() {
875            msg.metadata.insert("pi_details".to_string(), d.to_string());
876        }
877    }
878    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
879        msg.metadata
880            .insert("pi_from_hook".to_string(), "true".to_string());
881    }
882    out.push(msg);
883}
884
885/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
886/// rewind-with-summary) — same reconstruction caveat as
887/// [`push_pi_compaction`].
888fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
889    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
890    if summary.trim().is_empty() {
891        return;
892    }
893    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
894    msg.metadata
895        .insert("pi_type".to_string(), "branch_summary".to_string());
896    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
897        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
898    }
899    if let Some(d) = entry_v.get("details") {
900        if !d.is_null() {
901            msg.metadata.insert("pi_details".to_string(), d.to_string());
902        }
903    }
904    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
905        msg.metadata
906            .insert("pi_from_hook".to_string(), "true".to_string());
907    }
908    out.push(msg);
909}
910
911/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
912/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
913/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
914/// reads. The two carry genuinely different values in real pi corpora (a
915/// message-level clock reading vs. the entry's own wall-clock stamp), so this
916/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
917/// nested `message.timestamp` field, so a pi -> pi native round-trip
918/// preserves the source message-level clock value-exact instead of deriving
919/// it from the (distinct) entry-level timestamp. Falls back to
920/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
921/// reading (non-pi-sourced, or a synthesized/appended turn).
922fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
923    msg.metadata
924        .get("pi_msg_timestamp")
925        .and_then(|s| s.parse::<i64>().ok())
926        .unwrap_or(SYNTH_TS_MS)
927}
928
929impl Session {
930    /// Synthesize a fresh pi v3 session from the canonical `messages`
931    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
932    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
933    /// through `raw` + `to_native_jsonl(_v2)` instead).
934    pub(super) fn to_pi_jsonl(&self) -> String {
935        let session_id = self
936            .meta
937            .session_id
938            .clone()
939            .unwrap_or_else(|| synth_uuid(0));
940        let cwd = self.cwd_string();
941        let mut out = String::new();
942        push_pi_header(
943            &mut out,
944            &session_id,
945            &cwd,
946            self.meta
947                .lineage
948                .get("parent_session_path")
949                .map(String::as_str),
950            self.meta.lineage.get("created_at").map(String::as_str),
951            // D7: carry a captured Claude `fork-context-ref` (see
952            // `capture_claude_meta`) through the Pi hop too — mirrors the
953            // Codex hop's `claude_fork_context_ref` passthrough
954            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
955            // round trip doesn't silently lose fork lineage just because Pi
956            // has no native slot for it.
957            self.meta
958                .lineage
959                .get("claude_fork_context_ref_raw")
960                .map(String::as_str),
961        );
962        let mut used_ids: HashSet<String> = HashSet::new();
963        let mut counter: u64 = 0;
964        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
965        if let Some(extension) = native_residue_envelope(&self.meta) {
966            inject_first_jsonl_top_level(
967                &mut out,
968                SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY,
969                native_residue_summary(&extension),
970            );
971            inject_first_jsonl_top_level(&mut out, SUPERCODE_NATIVE_RESIDUE_KEY, extension);
972        }
973        out
974    }
975
976    /// Synthesize pi `message` entries for `messages` (a full session, or —
977    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
978    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
979    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
980    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
981    fn write_pi_entries(
982        &self,
983        out: &mut String,
984        messages: &[ChatMessage],
985        mut parent: Option<String>,
986        used_ids: &mut HashSet<String>,
987        counter: &mut u64,
988    ) {
989        // Claude Code and Codex do not repeat the tool name on their native
990        // tool-result records. Recover that redundant Pi field from the
991        // paired assistant call when a cross-format round trip therefore
992        // returns a canonical Tool message with `name == None`.
993        let mut paired_tool_names = HashMap::<String, String>::new();
994        for msg in messages {
995            if is_replay_excluded(msg) {
996                continue;
997            }
998            for call in msg.tool_calls() {
999                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
1000            }
1001            let id = pi_fresh_id(used_ids, counter);
1002            let mut entry = match msg.role {
1003                // B4: pi has no session-level system/developer PROMPT slot
1004                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
1005                // content-bearing `Role::System` message loaded from a real
1006                // Claude Code `type: "system"` record (`push_claude_system`'s
1007                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
1008                // `away_summary`) is NOT a system prompt — it's a real,
1009                // non-regenerable transcript event. Pi's own `role:"custom"`
1010                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
1011                // as a user message") is the closest existing, non-fabricated
1012                // slot pi's own parser already understands, so this
1013                // re-materializes the record there instead of silently
1014                // dropping it — the exact allowance push_claude_system's own
1015                // doc comment describes in reverse. `customType` is a
1016                // supercode-namespaced marker (`push_pi_custom_common`
1017                // recognizes it on reload and restores `Role::System` +
1018                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
1019                // produced in the first place); a real pi customType never
1020                // collides with this name. `details.claude_system_subtype`
1021                // carries the original subtype losslessly through the pi leg
1022                // (mirrors `write_codex_records`'s `claude_system_subtype`
1023                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
1024                // is never fabricated — only emitted when non-empty.
1025                Role::System => {
1026                    let content = msg.content.clone().unwrap_or_default();
1027                    if content.trim().is_empty() {
1028                        continue;
1029                    }
1030                    let subtype = msg
1031                        .metadata
1032                        .get("systemSubtype")
1033                        .cloned()
1034                        .unwrap_or_else(|| "local_command".to_string());
1035                    serde_json::json!({
1036                        "type": "message",
1037                        "id": id,
1038                        "parentId": parent,
1039                        "timestamp": msg_timestamp_or_synth(msg),
1040                        "message": {
1041                            "role": "custom",
1042                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
1043                            "content": content,
1044                            "display": true,
1045                            "details": {"claude_system_subtype": subtype},
1046                            "timestamp": msg_pi_native_timestamp_ms(msg),
1047                        },
1048                    })
1049                }
1050                Role::User => serde_json::json!({
1051                    "type": "message",
1052                    "id": id,
1053                    "parentId": parent,
1054                    "timestamp": msg_timestamp_or_synth(msg),
1055                    "message": {
1056                        "role": "user",
1057                        "content": pi_content_value(msg),
1058                        "timestamp": msg_pi_native_timestamp_ms(msg),
1059                    },
1060                }),
1061                Role::Assistant => {
1062                    let api = msg
1063                        .metadata
1064                        .get("pi_api")
1065                        .cloned()
1066                        .unwrap_or_else(|| "anthropic-messages".to_string());
1067                    let provider = msg
1068                        .metadata
1069                        .get("pi_provider")
1070                        .cloned()
1071                        .unwrap_or_else(|| "anthropic".to_string());
1072                    let model = self
1073                        .meta
1074                        .model
1075                        .clone()
1076                        .unwrap_or_else(|| "unknown".to_string());
1077                    let usage = msg
1078                        .metadata
1079                        .get("pi_usage")
1080                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
1081                        .unwrap_or_else(default_pi_usage);
1082                    let stop_reason = msg
1083                        .metadata
1084                        .get("pi_stop_reason")
1085                        .cloned()
1086                        .unwrap_or_else(|| "stop".to_string());
1087                    serde_json::json!({
1088                        "type": "message",
1089                        "id": id,
1090                        "parentId": parent,
1091                        "timestamp": msg_timestamp_or_synth(msg),
1092                        "message": {
1093                            "role": "assistant",
1094                            "content": pi_assistant_content_value(msg),
1095                            "api": api,
1096                            "provider": provider,
1097                            "model": model,
1098                            "usage": usage,
1099                            "stopReason": stop_reason,
1100                            "timestamp": msg_pi_native_timestamp_ms(msg),
1101                        },
1102                    })
1103                }
1104                Role::Tool => serde_json::json!({
1105                    "type": "message",
1106                    "id": id,
1107                    "parentId": parent,
1108                    "timestamp": msg_timestamp_or_synth(msg),
1109                    "message": {
1110                        "role": "toolResult",
1111                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
1112                        "toolName": msg.name.as_deref().or_else(|| {
1113                            msg.tool_call_id
1114                                .as_deref()
1115                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
1116                        }).unwrap_or_default(),
1117                        "content": pi_content_value(msg),
1118                        "isError": is_tool_error_flag(msg),
1119                        "timestamp": msg_pi_native_timestamp_ms(msg),
1120                    },
1121                }),
1122            };
1123            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
1124                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
1125            }
1126            set_grok_message_extension(&mut entry, self.meta.source, msg);
1127            push_jsonl(out, &entry);
1128            parent = Some(id);
1129            if msg.role == Role::Tool {
1130                if let Some(call_id) = msg.tool_call_id.as_deref() {
1131                    paired_tool_names.remove(call_id);
1132                }
1133            }
1134        }
1135    }
1136
1137    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
1138    /// **verbatim** — the header line always has its `version` normalized to
1139    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
1140    /// byte-identity, so the writer never re-emits one; this intentionally
1141    /// breaks byte-identity for pre-v3 originals only, the accepted
1142    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
1143    /// other raw line — every entry — is untouched (pi repeats the session
1144    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
1145    /// entries only for the appended tail via [`Self::write_pi_entries`],
1146    /// chaining from the last entry `id` found in the raw prefix.
1147    pub(super) fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
1148        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
1149        if raw_prefix_len == 0 {
1150            return Ok(self.to_pi_jsonl());
1151        }
1152
1153        let mut out = String::new();
1154        let mut used_ids: HashSet<String> = HashSet::new();
1155        let mut leaf: Option<String> = None;
1156        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
1157            if i == 0 {
1158                if let Ok(v) = serde_json::from_str::<Value>(line) {
1159                    if v.get("type").and_then(Value::as_str) == Some("session") {
1160                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
1161                        // Only reparse+reserialize the header when something
1162                        // actually needs to change — this crate doesn't
1163                        // enable serde_json's `preserve_order`, so a no-op
1164                        // round-trip through `Value` would reorder keys
1165                        // alphabetically and silently break the "prefix
1166                        // bytes unchanged" splice guarantee for the (common)
1167                        // already-v3, no-override case.
1168                        if needs_v3 || session_id.is_some() {
1169                            let mut v = v;
1170                            v["version"] = serde_json::json!(3);
1171                            if let Some(new_id) = session_id {
1172                                v["id"] = Value::String(new_id.to_string());
1173                            }
1174                            out.push_str(&v.to_string());
1175                            out.push('\n');
1176                            continue;
1177                        }
1178                    }
1179                }
1180            }
1181            out.push_str(line);
1182            out.push('\n');
1183            if let Ok(v) = serde_json::from_str::<Value>(line) {
1184                if let Some(id) = v.get("id").and_then(Value::as_str) {
1185                    used_ids.insert(id.to_string());
1186                    leaf = Some(id.to_string());
1187                }
1188            }
1189        }
1190
1191        let mut counter: u64 = 0;
1192        self.write_pi_entries(
1193            &mut out,
1194            &self.messages[message_prefix_len..],
1195            leaf,
1196            &mut used_ids,
1197            &mut counter,
1198        );
1199        Ok(out)
1200    }
1201}
1202
1203// ---- Pi writer helpers -----------------------------------------------------
1204
1205/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
1206/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
1207/// file in place on first resume (`pi-fields.md` sm:848-850).
1208fn push_pi_header(
1209    out: &mut String,
1210    id: &str,
1211    cwd: &str,
1212    parent_session: Option<&str>,
1213    created_at: Option<&str>,
1214    claude_fork_context_ref: Option<&str>,
1215) {
1216    let mut header = serde_json::json!({
1217        "type": "session",
1218        "version": 3,
1219        "id": id,
1220        "timestamp": created_at.unwrap_or(SYNTH_TS),
1221        "cwd": cwd,
1222    });
1223    if let Some(ps) = parent_session {
1224        header["parentSession"] = Value::String(ps.to_string());
1225    }
1226    // D7: namespaced passthrough field, exactly like the Codex writer's
1227    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
1228    // header keys, and `capture_pi_header` reads this same key back on
1229    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
1230    // fork-context-ref record instead of silently losing it on this hop.
1231    if let Some(raw) = claude_fork_context_ref {
1232        header["claude_fork_context_ref"] =
1233            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
1234    }
1235    push_jsonl(out, &header);
1236}
1237
1238/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
1239/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
1240/// deterministic here rather than random, which still satisfies "fresh,
1241/// collision-free" without an extra RNG dependency).
1242fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
1243    loop {
1244        *counter += 1;
1245        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
1246        let id = format!("{:08x}", (h >> 32) as u32);
1247        if used.insert(id.clone()) {
1248            return id;
1249        }
1250    }
1251}
1252
1253/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
1254/// inverse of the loader's `data:{mime};base64,{data}` construction.
1255pub(super) fn parse_data_uri(url: &str) -> Option<(String, String)> {
1256    let rest = url.strip_prefix("data:")?;
1257    let (meta, data) = rest.split_once(',')?;
1258    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
1259    Some((mime.to_string(), data.to_string()))
1260}
1261
1262/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
1263/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
1264/// `toolResult` entries (both use the identical union on the wire).
1265fn pi_content_value(msg: &ChatMessage) -> Value {
1266    if let Some(parts) = &msg.content_parts {
1267        let mut arr = Vec::new();
1268        for p in parts {
1269            match p.get("type").and_then(Value::as_str) {
1270                Some("text") => {
1271                    if let Some(t) = p.get("text").and_then(Value::as_str) {
1272                        arr.push(serde_json::json!({"type": "text", "text": t}));
1273                    }
1274                }
1275                Some("image_url") => {
1276                    if let Some(url) = p
1277                        .get("image_url")
1278                        .and_then(|u| u.get("url"))
1279                        .and_then(Value::as_str)
1280                    {
1281                        if let Some((mime, data)) = parse_data_uri(url) {
1282                            arr.push(
1283                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
1284                            );
1285                        }
1286                    }
1287                }
1288                _ => {}
1289            }
1290        }
1291        Value::Array(arr)
1292    } else {
1293        Value::String(msg.content.clone().unwrap_or_default())
1294    }
1295}
1296
1297fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
1298    let mut arr = Vec::new();
1299    if let Some(thinking) = msg.metadata.get("thinking") {
1300        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
1301        if let Some(sig) = msg.metadata.get("thinking_signature") {
1302            block["thinkingSignature"] = Value::String(sig.clone());
1303        }
1304        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
1305            block["redacted"] = Value::Bool(true);
1306        }
1307        arr.push(block);
1308    }
1309    if let Some(text) = &msg.content {
1310        if !text.is_empty() {
1311            let mut block = serde_json::json!({"type": "text", "text": text});
1312            if let Some(sig) = msg.metadata.get("pi_text_signature") {
1313                block["textSignature"] = Value::String(sig.clone());
1314            }
1315            arr.push(block);
1316        }
1317    }
1318    for tc in msg.tool_calls() {
1319        let args = tc
1320            .function
1321            .parsed_arguments()
1322            .unwrap_or_else(|_| Value::Object(Default::default()));
1323        let mut block = serde_json::json!({
1324            "type": "toolCall",
1325            "id": tc.id,
1326            "name": tc.function.name,
1327            "arguments": args,
1328        });
1329        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
1330            block["thoughtSignature"] = Value::String(sig.clone());
1331        }
1332        arr.push(block);
1333    }
1334    Value::Array(arr)
1335}
1336
1337fn default_pi_usage() -> Value {
1338    serde_json::json!({
1339        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
1340        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
1341    })
1342}
1343
1344fn is_tool_error_flag(msg: &ChatMessage) -> bool {
1345    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
1346}