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        // Each message's creating record, by the entry id the loader stamped on it.
311        let record_of_entry: HashMap<String, usize> = raw_lines
312            .iter()
313            .enumerate()
314            .filter_map(|(index, line)| {
315                let value: Value = serde_json::from_str(line).ok()?;
316                Some((value.get("id")?.as_str()?.to_string(), index))
317            })
318            .collect();
319        meta.message_records = messages
320            .iter()
321            .map(|message| {
322                message
323                    .metadata
324                    .get("pi_entry_id")
325                    .and_then(|id| record_of_entry.get(id).copied())
326            })
327            .collect();
328        let imported_message_count = Some(messages.len());
329        Ok(Session {
330            meta,
331            messages,
332            subagents: Vec::new(),
333            raw,
334            raw_trailing_newline,
335            imported_message_count,
336            // Pi is line-oriented: `raw` is split directly out of the
337            // source text (strict-verbatim, IX-1).
338            raw_is_verbatim: true,
339            parse_error_lines,
340            load_residue: Vec::new(),
341        })
342    }
343}
344
345// ---- Pi ---------------------------------------------------------------
346
347fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
348    restore_codex_provenance_from_top_level(v, meta)?;
349    if let Some(id) = v.get("id").and_then(Value::as_str) {
350        meta.session_id = Some(id.to_string());
351    }
352    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
353        meta.cwd = Some(PathBuf::from(cwd));
354    }
355    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
356    let version = v
357        .get("version")
358        .and_then(Value::as_u64)
359        .map(|n| n.to_string())
360        .unwrap_or_else(|| "1".to_string());
361    meta.lineage.insert("pi_version".to_string(), version);
362    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
363        meta.lineage
364            .insert("created_at".to_string(), ts.to_string());
365    }
366    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
367        meta.lineage
368            .insert("parent_session_path".to_string(), ps.to_string());
369    }
370    // D7: the other half of `push_pi_header`'s passthrough — restores a
371    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
372    // trip reconstructs the original record (mirrors
373    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
374    // restore for the Codex hop).
375    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
376        if let Some(v) = v.get("claude_fork_context_ref") {
377            meta.lineage
378                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
379        }
380    }
381    Ok(())
382}
383
384/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
385/// `(mime, data)` when it looks like a real image payload.
386///
387/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
388/// `ai:316-350` for the `ImageContent` content-block union but does not
389/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
390/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
391/// Anthropic multimodal wire shape) is this loader's best guess, not a
392/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
393/// against a real pi corpus. Until then this function VALIDATES rather than
394/// assumes: both fields must be present, non-empty strings, and `data` must
395/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
396/// else is an unknown/unexpected image shape, and the caller must route the
397/// whole message to raw-only survival (S6-style fail loud) instead of
398/// silently synthesizing a corrupt/empty `image_url` part.
399fn pi_image_shape(item: &Value) -> Option<(String, String)> {
400    let mime = item.get("mimeType").and_then(Value::as_str)?;
401    let data = item.get("data").and_then(Value::as_str)?;
402    if mime.is_empty() || data.is_empty() {
403        return None;
404    }
405    if !data
406        .bytes()
407        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
408    {
409        return None;
410    }
411    Some((mime.to_string(), data.to_string()))
412}
413
414/// True if `content` (a pi content value: bare string or
415/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
416/// that does not match [`pi_image_shape`] — shared by the loader (which
417/// routes such a message to raw-only survival, never a synthesized-empty
418/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
419/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
420/// mismatch surfaces as a coverage FAILURE rather than vanishing.
421#[doc(hidden)]
422pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
423    let Some(Value::Array(items)) = content else {
424        return false;
425    };
426    items.iter().any(|item| {
427        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
428    })
429}
430
431/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
432/// into concatenated text plus, when a WELL-FORMED image block is present,
433/// the full `content_parts` array (leading text block + one `image_url` part
434/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
435/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
436/// the identical union (`pi-fields.md` §3a/§3c/§3e).
437///
438/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
439/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
440/// value that isn't recognizable base64), this NEVER synthesizes an empty/
441/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
442/// every caller must treat that as raw-only survival for the whole message
443/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
444/// guessed wrong fails loud instead of silently dropping/corrupting the
445/// image.
446fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
447    match content {
448        Some(Value::String(s)) => (s.clone(), None, false),
449        Some(Value::Array(items)) => {
450            let mut text = String::new();
451            let mut parts: Vec<Value> = Vec::new();
452            let mut has_image = false;
453            let mut unknown_image_shape = false;
454            for item in items {
455                match item.get("type").and_then(Value::as_str) {
456                    Some("text") => {
457                        if let Some(t) = item.get("text").and_then(Value::as_str) {
458                            push_str_field(&mut text, t);
459                        }
460                    }
461                    Some("image") => {
462                        has_image = true;
463                        match pi_image_shape(item) {
464                            Some((mime, data)) => {
465                                parts.push(serde_json::json!({
466                                    "type": "image_url",
467                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
468                                }));
469                            }
470                            None => unknown_image_shape = true,
471                        }
472                    }
473                    _ => {}
474                }
475            }
476            if unknown_image_shape {
477                // Never synthesize an empty/corrupt part for a shape we
478                // don't recognize — raw-only survival for the whole message;
479                // the coverage guard is what turns this into a visible
480                // failure (S6-style).
481                return (String::new(), None, true);
482            }
483            if has_image {
484                if !text.trim().is_empty() {
485                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
486                }
487                (text, Some(parts), false)
488            } else {
489                (text, None, false)
490            }
491        }
492        _ => (String::new(), None, false),
493    }
494}
495
496fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
497    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
498    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
499    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
500    // `message/UnknownImageShape` bucket is what turns this into a visible
501    // coverage failure.
502    if unknown_image_shape {
503        return;
504    }
505    if text.trim().is_empty() && parts.is_none() {
506        return;
507    }
508    let mut msg = match parts {
509        Some(parts) => ChatMessage {
510            role: Role::User,
511            content: None,
512            content_parts: Some(parts),
513            tool_calls: None,
514            tool_call_id: None,
515            name: None,
516            metadata: Default::default(),
517        },
518        None => ChatMessage::user(text),
519    };
520    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
521    // (`message.timestamp`) is a DISTINCT field from the canonical
522    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
523    // carry genuinely different values in real corpora (the fixture's are
524    // ~6 months apart). Preserve it separately so it isn't silently lost for
525    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
526    // native round-trip consumer) and the INHERENT residue note on
527    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
528    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
529        msg.metadata
530            .insert("pi_msg_timestamp".to_string(), ts.to_string());
531    }
532    out.push(msg);
533}
534
535fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
536    let mut text = String::new();
537    let mut calls: Vec<ToolCall> = Vec::new();
538    let mut thinking = String::new();
539    let mut thinking_seen = false;
540    let mut thinking_sig: Option<String> = None;
541    let mut thinking_redacted = false;
542    let mut text_sig: Option<String> = None;
543    let mut thought_sig: Option<String> = None;
544
545    if let Some(Value::Array(blocks)) = msg_v.get("content") {
546        for b in blocks {
547            match b.get("type").and_then(Value::as_str) {
548                Some("text") => {
549                    if let Some(t) = b.get("text").and_then(Value::as_str) {
550                        push_str_field(&mut text, t);
551                    }
552                    if let Some(sig) = b.get("textSignature") {
553                        text_sig = Some(match sig {
554                            Value::String(s) => s.clone(),
555                            other => other.to_string(),
556                        });
557                    }
558                }
559                Some("thinking") => {
560                    thinking_seen = true;
561                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
562                        push_str_field(&mut thinking, t);
563                    }
564                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
565                        thinking_sig = Some(sig.to_string());
566                    }
567                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
568                        thinking_redacted = true;
569                    }
570                }
571                Some("toolCall") => {
572                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
573                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
574                    // `arguments` is a JSON OBJECT on pi's wire, not a string
575                    // (`pi-fields.md` §3b open question 4) — serialize to the
576                    // string `FunctionCall::arguments` expects.
577                    let args = b
578                        .get("arguments")
579                        .cloned()
580                        .unwrap_or_else(|| Value::Object(Default::default()));
581                    calls.push(function_call(id, name, args.to_string()));
582                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
583                        thought_sig = Some(sig.to_string());
584                    }
585                }
586                _ => {}
587            }
588        }
589    }
590
591    let before = out.len();
592    push_assistant(out, text, calls);
593    // A recognized native assistant entry remains transcript state even
594    // when its content array is empty, except Pi's explicit empty error
595    // response: that record has no replayable content and is established
596    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
597    // non-error turns and Pi's standalone thinking-block shape.
598    let is_empty_error =
599        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
600    if out.len() == before && !is_empty_error {
601        let mut empty = ChatMessage {
602            role: Role::Assistant,
603            content: None,
604            content_parts: None,
605            tool_calls: None,
606            tool_call_id: None,
607            name: None,
608            metadata: Default::default(),
609        };
610        if !thinking_seen {
611            empty
612                .metadata
613                .insert("empty_assistant_record".to_string(), "true".to_string());
614        }
615        out.push(empty);
616    }
617    if out.len() > before {
618        let msg = out.last_mut().expect("just pushed");
619        if thinking_seen {
620            msg.metadata.insert("thinking".to_string(), thinking);
621        }
622        if let Some(s) = thinking_sig {
623            msg.metadata.insert("thinking_signature".to_string(), s);
624        }
625        if thinking_redacted {
626            msg.metadata
627                .insert("pi_thinking_redacted".to_string(), "true".to_string());
628        }
629        if let Some(s) = text_sig {
630            msg.metadata.insert("pi_text_signature".to_string(), s);
631        }
632        if let Some(s) = thought_sig {
633            msg.metadata.insert("pi_thought_signature".to_string(), s);
634        }
635        for (key, field) in [
636            ("pi_api", "api"),
637            ("pi_provider", "provider"),
638            ("pi_response_model", "responseModel"),
639            ("pi_response_id", "responseId"),
640            ("pi_stop_reason", "stopReason"),
641            ("pi_error_message", "errorMessage"),
642        ] {
643            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
644                msg.metadata.insert(key.to_string(), s.to_string());
645            }
646        }
647        if let Some(diag) = msg_v.get("diagnostics") {
648            if !diag.is_null() {
649                msg.metadata
650                    .insert("pi_diagnostics".to_string(), diag.to_string());
651            }
652        }
653        if let Some(usage) = msg_v.get("usage") {
654            if !usage.is_null() {
655                msg.metadata
656                    .insert("pi_usage".to_string(), usage.to_string());
657            }
658        }
659        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
660        // separately from the canonical entry-level ISO `timestamp` — see
661        // `push_pi_user`.
662        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
663            msg.metadata
664                .insert("pi_msg_timestamp".to_string(), ts.to_string());
665        }
666    }
667}
668
669fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
670    let id = msg_v
671        .get("toolCallId")
672        .and_then(Value::as_str)
673        .unwrap_or_default();
674    let name = msg_v
675        .get("toolName")
676        .and_then(Value::as_str)
677        .unwrap_or_default();
678    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
679    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
680    // survival, never a synthesized-empty part. Dropping the toolResult
681    // message here leaves its `toolCallId` unanswered, which
682    // `ensure_tool_results_paired` already turns into a visible
683    // "[no tool result recorded — turn interrupted]" placeholder — a loud
684    // failure mode, not a silent one.
685    if unknown_image_shape {
686        return;
687    }
688    let mut msg = ChatMessage {
689        role: Role::Tool,
690        content: Some(text),
691        content_parts: parts,
692        tool_calls: None,
693        tool_call_id: Some(id.to_string()),
694        name: Some(name.to_string()),
695        metadata: Default::default(),
696    };
697    if let Some(details) = msg_v.get("details") {
698        if !details.is_null() {
699            msg.metadata
700                .insert("pi_tool_details".to_string(), details.to_string());
701        }
702    }
703    let is_error = msg_v
704        .get("isError")
705        .and_then(Value::as_bool)
706        .unwrap_or(false);
707    msg.metadata
708        .insert("pi_is_error".to_string(), is_error.to_string());
709    if is_error {
710        crate::mark_tool_error(&mut msg);
711    }
712    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
713    // separately from the canonical entry-level ISO `timestamp` — see
714    // `push_pi_user`.
715    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
716        msg.metadata
717            .insert("pi_msg_timestamp".to_string(), ts.to_string());
718    }
719    out.push(msg);
720}
721
722/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
723/// pi itself sends the model, mirroring `bashExecutionToText`
724/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
725/// aren't reproduced in the frozen research doc (only cited by file:line),
726/// so this is a faithful, clearly-labeled reconstruction — every structured
727/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
728fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
729    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
730    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
731    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
732    let cancelled = msg_v
733        .get("cancelled")
734        .and_then(Value::as_bool)
735        .unwrap_or(false);
736    let truncated = msg_v
737        .get("truncated")
738        .and_then(Value::as_bool)
739        .unwrap_or(false);
740
741    let mut text = format!("$ {command}\n{output}");
742    if let Some(code) = exit_code {
743        if code != 0 {
744            text.push_str(&format!("\n[exit code: {code}]"));
745        }
746    }
747    if cancelled {
748        text.push_str("\n[cancelled]");
749    }
750    if truncated {
751        text.push_str("\n[truncated]");
752    }
753
754    let mut msg = ChatMessage::user(text);
755    msg.metadata
756        .insert("pi_bash_command".to_string(), command.to_string());
757    msg.metadata
758        .insert("pi_bash_output".to_string(), output.to_string());
759    if let Some(code) = exit_code {
760        msg.metadata
761            .insert("pi_bash_exit_code".to_string(), code.to_string());
762    }
763    msg.metadata
764        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
765    msg.metadata
766        .insert("pi_bash_truncated".to_string(), truncated.to_string());
767    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
768        msg.metadata
769            .insert("pi_bash_full_output_path".to_string(), p.to_string());
770    }
771    // `!!` — hidden from the model context; honored by `is_replay_excluded`
772    // on every writer, not just pi's own (§2.2).
773    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
774        msg.metadata
775            .insert("pi_exclude_from_context".to_string(), "true".to_string());
776    }
777    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
778    // separately from the canonical entry-level ISO `timestamp` — see
779    // `push_pi_user`.
780    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
781        msg.metadata
782            .insert("pi_msg_timestamp".to_string(), ts.to_string());
783    }
784    out.push(msg);
785}
786
787/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
788/// stamps on a re-materialized content-bearing Claude `system` record (see
789/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
790/// never collide with a real pi `CustomMessage.customType` — pi's own
791/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
792/// migration targets), never this literal string.
793const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
794
795/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
796/// `custom_message` entries (§9) — both enter context as a `User` message
797/// with the same `customType`/`display`/`details` residue.
798///
799/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
800/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
801/// actually a re-materialized content-bearing Claude `system` record round-
802/// tripping through pi, not a genuine pi extension message — restore
803/// `Role::System` + `metadata["systemSubtype"]` (from `details.
804/// claude_system_subtype`, falling back to `local_command` — still one of
805/// `push_claude_system`'s own keep subtypes — exactly like
806/// `write_codex_records`'s Codex-leg fallback) instead of the generic
807/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
808/// the exact original role, not just the text.
809fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
810    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
811        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
812        if content.trim().is_empty() {
813            return;
814        }
815        let subtype = v
816            .get("details")
817            .and_then(|d| d.get("claude_system_subtype"))
818            .and_then(Value::as_str)
819            .unwrap_or("local_command");
820        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
821        return;
822    }
823    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
824    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
825    // survival, never a synthesized-empty part.
826    if unknown_image_shape {
827        return;
828    }
829    if text.trim().is_empty() && parts.is_none() {
830        return;
831    }
832    let mut msg = match parts {
833        Some(parts) => ChatMessage {
834            role: Role::User,
835            content: None,
836            content_parts: Some(parts),
837            tool_calls: None,
838            tool_call_id: None,
839            name: None,
840            metadata: Default::default(),
841        },
842        None => ChatMessage::user(text),
843    };
844    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
845        msg.metadata
846            .insert("pi_custom_type".to_string(), ct.to_string());
847    }
848    if let Some(d) = v.get("display").and_then(Value::as_bool) {
849        msg.metadata.insert("pi_display".to_string(), d.to_string());
850    }
851    if let Some(details) = v.get("details") {
852        if !details.is_null() {
853            msg.metadata
854                .insert("pi_details".to_string(), details.to_string());
855        }
856    }
857    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
858    // separately from the canonical entry-level ISO `timestamp` — see
859    // `push_pi_user`. `v` here is the `message` object for the `role:
860    // "custom"` case; for the top-level `custom_message` case `v` is the
861    // entry itself, whose `timestamp` is the entry-level ISO string (not a
862    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
863    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
864        msg.metadata
865            .insert("pi_msg_timestamp".to_string(), ts.to_string());
866    }
867    out.push(msg);
868}
869
870/// pi's own prefix-wrapped user text for a `compaction` entry summary
871/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
872/// The exact upstream wrapper string is cited (`msg:11-17`) but not
873/// reproduced in the frozen research doc; this is a clearly-labeled
874/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
875fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
876    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
877    if summary.trim().is_empty() {
878        return;
879    }
880    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
881    msg.metadata
882        .insert("pi_type".to_string(), "compaction".to_string());
883    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
884        msg.metadata
885            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
886    }
887    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
888        msg.metadata
889            .insert("pi_tokens_before".to_string(), tb.to_string());
890    }
891    if let Some(d) = entry_v.get("details") {
892        if !d.is_null() {
893            msg.metadata.insert("pi_details".to_string(), d.to_string());
894        }
895    }
896    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
897        msg.metadata
898            .insert("pi_from_hook".to_string(), "true".to_string());
899    }
900    out.push(msg);
901}
902
903/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
904/// rewind-with-summary) — same reconstruction caveat as
905/// [`push_pi_compaction`].
906fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
907    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
908    if summary.trim().is_empty() {
909        return;
910    }
911    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
912    msg.metadata
913        .insert("pi_type".to_string(), "branch_summary".to_string());
914    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
915        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
916    }
917    if let Some(d) = entry_v.get("details") {
918        if !d.is_null() {
919            msg.metadata.insert("pi_details".to_string(), d.to_string());
920        }
921    }
922    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
923        msg.metadata
924            .insert("pi_from_hook".to_string(), "true".to_string());
925    }
926    out.push(msg);
927}
928
929/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
930/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
931/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
932/// reads. The two carry genuinely different values in real pi corpora (a
933/// message-level clock reading vs. the entry's own wall-clock stamp), so this
934/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
935/// nested `message.timestamp` field, so a pi -> pi native round-trip
936/// preserves the source message-level clock value-exact instead of deriving
937/// it from the (distinct) entry-level timestamp. Falls back to
938/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
939/// reading (non-pi-sourced, or a synthesized/appended turn).
940fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
941    msg.metadata
942        .get("pi_msg_timestamp")
943        .and_then(|s| s.parse::<i64>().ok())
944        .unwrap_or(SYNTH_TS_MS)
945}
946
947impl Session {
948    /// Synthesize a fresh pi v3 session from the canonical `messages`
949    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
950    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
951    /// through `raw` + `to_native_jsonl(_v2)` instead).
952    pub(super) fn to_pi_jsonl(&self) -> String {
953        let session_id = self
954            .meta
955            .session_id
956            .clone()
957            .unwrap_or_else(|| synth_uuid(0));
958        let cwd = self.cwd_string();
959        let mut out = String::new();
960        push_pi_header(
961            &mut out,
962            &session_id,
963            &cwd,
964            self.meta
965                .lineage
966                .get("parent_session_path")
967                .map(String::as_str),
968            self.meta.lineage.get("created_at").map(String::as_str),
969            // D7: carry a captured Claude `fork-context-ref` (see
970            // `capture_claude_meta`) through the Pi hop too — mirrors the
971            // Codex hop's `claude_fork_context_ref` passthrough
972            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
973            // round trip doesn't silently lose fork lineage just because Pi
974            // has no native slot for it.
975            self.meta
976                .lineage
977                .get("claude_fork_context_ref_raw")
978                .map(String::as_str),
979        );
980        let mut used_ids: HashSet<String> = HashSet::new();
981        let mut counter: u64 = 0;
982        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
983        if let Some(extension) = native_residue_envelope(&self.meta) {
984            inject_first_jsonl_top_level(
985                &mut out,
986                SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY,
987                native_residue_summary(&extension),
988            );
989            inject_first_jsonl_top_level(&mut out, SUPERCODE_NATIVE_RESIDUE_KEY, extension);
990        }
991        out
992    }
993
994    /// Synthesize pi `message` entries for `messages` (a full session, or —
995    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
996    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
997    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
998    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
999    fn write_pi_entries(
1000        &self,
1001        out: &mut String,
1002        messages: &[ChatMessage],
1003        mut parent: Option<String>,
1004        used_ids: &mut HashSet<String>,
1005        counter: &mut u64,
1006    ) {
1007        // Claude Code and Codex do not repeat the tool name on their native
1008        // tool-result records. Recover that redundant Pi field from the
1009        // paired assistant call when a cross-format round trip therefore
1010        // returns a canonical Tool message with `name == None`.
1011        let mut paired_tool_names = HashMap::<String, String>::new();
1012        for msg in messages {
1013            if is_replay_excluded(msg) {
1014                continue;
1015            }
1016            for call in msg.tool_calls() {
1017                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
1018            }
1019            let id = pi_fresh_id(used_ids, counter);
1020            let mut entry = match msg.role {
1021                // B4: pi has no session-level system/developer PROMPT slot
1022                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
1023                // content-bearing `Role::System` message loaded from a real
1024                // Claude Code `type: "system"` record (`push_claude_system`'s
1025                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
1026                // `away_summary`) is NOT a system prompt — it's a real,
1027                // non-regenerable transcript event. Pi's own `role:"custom"`
1028                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
1029                // as a user message") is the closest existing, non-fabricated
1030                // slot pi's own parser already understands, so this
1031                // re-materializes the record there instead of silently
1032                // dropping it — the exact allowance push_claude_system's own
1033                // doc comment describes in reverse. `customType` is a
1034                // supercode-namespaced marker (`push_pi_custom_common`
1035                // recognizes it on reload and restores `Role::System` +
1036                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
1037                // produced in the first place); a real pi customType never
1038                // collides with this name. `details.claude_system_subtype`
1039                // carries the original subtype losslessly through the pi leg
1040                // (mirrors `write_codex_records`'s `claude_system_subtype`
1041                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
1042                // is never fabricated — only emitted when non-empty.
1043                Role::System => {
1044                    let content = msg.content.clone().unwrap_or_default();
1045                    if content.trim().is_empty() {
1046                        continue;
1047                    }
1048                    let subtype = msg
1049                        .metadata
1050                        .get("systemSubtype")
1051                        .cloned()
1052                        .unwrap_or_else(|| "local_command".to_string());
1053                    serde_json::json!({
1054                        "type": "message",
1055                        "id": id,
1056                        "parentId": parent,
1057                        "timestamp": msg_timestamp_or_synth(msg),
1058                        "message": {
1059                            "role": "custom",
1060                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
1061                            "content": content,
1062                            "display": true,
1063                            "details": {"claude_system_subtype": subtype},
1064                            "timestamp": msg_pi_native_timestamp_ms(msg),
1065                        },
1066                    })
1067                }
1068                Role::User => serde_json::json!({
1069                    "type": "message",
1070                    "id": id,
1071                    "parentId": parent,
1072                    "timestamp": msg_timestamp_or_synth(msg),
1073                    "message": {
1074                        "role": "user",
1075                        "content": pi_content_value(msg),
1076                        "timestamp": msg_pi_native_timestamp_ms(msg),
1077                    },
1078                }),
1079                Role::Assistant => {
1080                    let api = msg
1081                        .metadata
1082                        .get("pi_api")
1083                        .cloned()
1084                        .unwrap_or_else(|| "anthropic-messages".to_string());
1085                    let provider = msg
1086                        .metadata
1087                        .get("pi_provider")
1088                        .cloned()
1089                        .unwrap_or_else(|| "anthropic".to_string());
1090                    let model = self
1091                        .meta
1092                        .model
1093                        .clone()
1094                        .unwrap_or_else(|| "unknown".to_string());
1095                    let usage = msg
1096                        .metadata
1097                        .get("pi_usage")
1098                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
1099                        .unwrap_or_else(default_pi_usage);
1100                    let stop_reason = msg
1101                        .metadata
1102                        .get("pi_stop_reason")
1103                        .cloned()
1104                        .unwrap_or_else(|| "stop".to_string());
1105                    serde_json::json!({
1106                        "type": "message",
1107                        "id": id,
1108                        "parentId": parent,
1109                        "timestamp": msg_timestamp_or_synth(msg),
1110                        "message": {
1111                            "role": "assistant",
1112                            "content": pi_assistant_content_value(msg),
1113                            "api": api,
1114                            "provider": provider,
1115                            "model": model,
1116                            "usage": usage,
1117                            "stopReason": stop_reason,
1118                            "timestamp": msg_pi_native_timestamp_ms(msg),
1119                        },
1120                    })
1121                }
1122                Role::Tool => serde_json::json!({
1123                    "type": "message",
1124                    "id": id,
1125                    "parentId": parent,
1126                    "timestamp": msg_timestamp_or_synth(msg),
1127                    "message": {
1128                        "role": "toolResult",
1129                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
1130                        "toolName": msg.name.as_deref().or_else(|| {
1131                            msg.tool_call_id
1132                                .as_deref()
1133                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
1134                        }).unwrap_or_default(),
1135                        "content": pi_tool_result_content(msg),
1136                        "isError": is_tool_error_flag(msg),
1137                        "timestamp": msg_pi_native_timestamp_ms(msg),
1138                    },
1139                }),
1140            };
1141            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
1142                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
1143            }
1144            set_grok_message_extension(&mut entry, self.meta.source, msg);
1145            push_jsonl(out, &entry);
1146            parent = Some(id);
1147            if msg.role == Role::Tool {
1148                if let Some(call_id) = msg.tool_call_id.as_deref() {
1149                    paired_tool_names.remove(call_id);
1150                }
1151            }
1152        }
1153    }
1154
1155    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
1156    /// **verbatim** — the header line always has its `version` normalized to
1157    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
1158    /// byte-identity, so the writer never re-emits one; this intentionally
1159    /// breaks byte-identity for pre-v3 originals only, the accepted
1160    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
1161    /// other raw line — every entry — is untouched (pi repeats the session
1162    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
1163    /// entries only for the appended tail via [`Self::write_pi_entries`],
1164    /// chaining from the last entry `id` found in the raw prefix.
1165    pub(super) fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
1166        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
1167        if raw_prefix_len == 0 {
1168            return Ok(self.to_pi_jsonl());
1169        }
1170
1171        let mut out = String::new();
1172        let mut used_ids: HashSet<String> = HashSet::new();
1173        let mut leaf: Option<String> = None;
1174        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
1175            if i == 0 {
1176                if let Ok(v) = serde_json::from_str::<Value>(line) {
1177                    if v.get("type").and_then(Value::as_str) == Some("session") {
1178                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
1179                        // Only reparse+reserialize the header when something
1180                        // actually needs to change — this crate doesn't
1181                        // enable serde_json's `preserve_order`, so a no-op
1182                        // round-trip through `Value` would reorder keys
1183                        // alphabetically and silently break the "prefix
1184                        // bytes unchanged" splice guarantee for the (common)
1185                        // already-v3, no-override case.
1186                        if needs_v3 || session_id.is_some() {
1187                            let mut v = v;
1188                            v["version"] = serde_json::json!(3);
1189                            if let Some(new_id) = session_id {
1190                                v["id"] = Value::String(new_id.to_string());
1191                            }
1192                            out.push_str(&v.to_string());
1193                            out.push('\n');
1194                            continue;
1195                        }
1196                    }
1197                }
1198            }
1199            out.push_str(line);
1200            out.push('\n');
1201            if let Ok(v) = serde_json::from_str::<Value>(line) {
1202                if let Some(id) = v.get("id").and_then(Value::as_str) {
1203                    used_ids.insert(id.to_string());
1204                    leaf = Some(id.to_string());
1205                }
1206            }
1207        }
1208
1209        let mut counter: u64 = 0;
1210        self.write_pi_entries(
1211            &mut out,
1212            &self.messages[message_prefix_len..],
1213            leaf,
1214            &mut used_ids,
1215            &mut counter,
1216        );
1217        Ok(out)
1218    }
1219}
1220
1221// ---- Pi writer helpers -----------------------------------------------------
1222
1223/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
1224/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
1225/// file in place on first resume (`pi-fields.md` sm:848-850).
1226fn push_pi_header(
1227    out: &mut String,
1228    id: &str,
1229    cwd: &str,
1230    parent_session: Option<&str>,
1231    created_at: Option<&str>,
1232    claude_fork_context_ref: Option<&str>,
1233) {
1234    let mut header = serde_json::json!({
1235        "type": "session",
1236        "version": 3,
1237        "id": id,
1238        "timestamp": created_at.unwrap_or(SYNTH_TS),
1239        "cwd": cwd,
1240    });
1241    if let Some(ps) = parent_session {
1242        header["parentSession"] = Value::String(ps.to_string());
1243    }
1244    // D7: namespaced passthrough field, exactly like the Codex writer's
1245    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
1246    // header keys, and `capture_pi_header` reads this same key back on
1247    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
1248    // fork-context-ref record instead of silently losing it on this hop.
1249    if let Some(raw) = claude_fork_context_ref {
1250        header["claude_fork_context_ref"] =
1251            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
1252    }
1253    push_jsonl(out, &header);
1254}
1255
1256/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
1257/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
1258/// deterministic here rather than random, which still satisfies "fresh,
1259/// collision-free" without an extra RNG dependency).
1260fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
1261    loop {
1262        *counter += 1;
1263        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
1264        let id = format!("{:08x}", (h >> 32) as u32);
1265        if used.insert(id.clone()) {
1266            return id;
1267        }
1268    }
1269}
1270
1271/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
1272/// inverse of the loader's `data:{mime};base64,{data}` construction.
1273pub(super) fn parse_data_uri(url: &str) -> Option<(String, String)> {
1274    let rest = url.strip_prefix("data:")?;
1275    let (meta, data) = rest.split_once(',')?;
1276    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
1277    Some((mime.to_string(), data.to_string()))
1278}
1279
1280/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
1281/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
1282/// `toolResult` entries (both use the identical union on the wire).
1283fn pi_content_value(msg: &ChatMessage) -> Value {
1284    if let Some(parts) = &msg.content_parts {
1285        let mut arr = Vec::new();
1286        for p in parts {
1287            match p.get("type").and_then(Value::as_str) {
1288                Some("text") => {
1289                    if let Some(t) = p.get("text").and_then(Value::as_str) {
1290                        arr.push(serde_json::json!({"type": "text", "text": t}));
1291                    }
1292                }
1293                Some("image_url") => {
1294                    if let Some(url) = p
1295                        .get("image_url")
1296                        .and_then(|u| u.get("url"))
1297                        .and_then(Value::as_str)
1298                    {
1299                        if let Some((mime, data)) = parse_data_uri(url) {
1300                            arr.push(
1301                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
1302                            );
1303                        }
1304                    }
1305                }
1306                _ => {}
1307            }
1308        }
1309        Value::Array(arr)
1310    } else {
1311        Value::String(msg.content.clone().unwrap_or_default())
1312    }
1313}
1314
1315/// A Pi `toolResult`'s content is always a block list (a user message may be a bare string):
1316/// stock Pi reads a string here character by character and the resumed model sees blank output.
1317fn pi_tool_result_content(msg: &ChatMessage) -> Value {
1318    match pi_content_value(msg) {
1319        Value::String(text) if text.is_empty() => Value::Array(Vec::new()),
1320        Value::String(text) => serde_json::json!([{"type": "text", "text": text}]),
1321        blocks => blocks,
1322    }
1323}
1324
1325fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
1326    let mut arr = Vec::new();
1327    if let Some(thinking) = msg.metadata.get("thinking") {
1328        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
1329        if let Some(sig) = msg.metadata.get("thinking_signature") {
1330            block["thinkingSignature"] = Value::String(sig.clone());
1331        }
1332        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
1333            block["redacted"] = Value::Bool(true);
1334        }
1335        arr.push(block);
1336    }
1337    if let Some(text) = &msg.content {
1338        if !text.is_empty() {
1339            let mut block = serde_json::json!({"type": "text", "text": text});
1340            if let Some(sig) = msg.metadata.get("pi_text_signature") {
1341                block["textSignature"] = Value::String(sig.clone());
1342            }
1343            arr.push(block);
1344        }
1345    }
1346    for tc in msg.tool_calls() {
1347        let args = tc
1348            .function
1349            .parsed_arguments()
1350            .unwrap_or_else(|_| Value::Object(Default::default()));
1351        let mut block = serde_json::json!({
1352            "type": "toolCall",
1353            "id": tc.id,
1354            "name": tc.function.name,
1355            "arguments": args,
1356        });
1357        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
1358            block["thoughtSignature"] = Value::String(sig.clone());
1359        }
1360        arr.push(block);
1361    }
1362    Value::Array(arr)
1363}
1364
1365fn default_pi_usage() -> Value {
1366    serde_json::json!({
1367        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
1368        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
1369    })
1370}
1371
1372fn is_tool_error_flag(msg: &ChatMessage) -> bool {
1373    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
1374}