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