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}