Skip to main content

txcript/harness/
pi.rs

1//! pi (`@mariozechner/pi-coding-agent`): one JSONL file per session under
2//! `~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl`.
3//!
4//! The first line is a `session` header; the rest are tree nodes chained via
5//! `id`/`parentId`, read in file order (linear is correct for the common
6//! single-branch session). Conversational entries are `message` lines whose
7//! `message.role` is `user`/`assistant`/`toolResult`/`bashExecution`, plus
8//! `custom_message`. `model_change`/`session_info` carry metadata; the rest is
9//! bookkeeping, preserved in [`Record::Other`] so native ↔ disk is lossless.
10//!
11//! The codec helpers here are `pub(crate)` and shared verbatim by Campfire,
12//! which is the identical format under a different home.
13
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use chrono::{DateTime, SecondsFormat, Utc};
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value, json};
21use uuid::Uuid;
22
23use crate::common::{Block, ImageSource, Message, Meta, Role, StopReason, Tool, ToolOutput, Usage};
24use crate::error::Result;
25use crate::harness::jsonl;
26use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript};
27
28/// The pi harness marker.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Pi;
31
32impl Harness for Pi {
33    const NAME: &'static str = "pi";
34    type Body = Vec<Record>;
35}
36
37// ── native records ─────────────────────────────────────────────────────
38
39/// One JSONL line. The message union is preserved as raw JSON and typed by the
40/// codec.
41#[derive(Debug, Clone, PartialEq)]
42pub enum Record {
43    Session(SessionHeader),
44    Message(MessageEntry),
45    Custom(CustomEntry),
46    Other(Value),
47}
48
49/// The leading `session` header.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct SessionHeader {
52    pub id: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub timestamp: Option<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub cwd: Option<String>,
57    #[serde(flatten)]
58    pub extra: Map<String, Value>,
59}
60
61/// A `message` line: the tree envelope plus the raw message payload.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct MessageEntry {
64    pub id: String,
65    #[serde(rename = "parentId", default, skip_serializing_if = "Option::is_none")]
66    pub parent_id: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub timestamp: Option<String>,
69    pub message: Value,
70    #[serde(flatten)]
71    pub extra: Map<String, Value>,
72}
73
74/// A `custom_message` line — extension context that replays as a user turn.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct CustomEntry {
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub id: Option<String>,
79    #[serde(rename = "parentId", default, skip_serializing_if = "Option::is_none")]
80    pub parent_id: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub timestamp: Option<String>,
83    pub content: Value,
84    #[serde(flatten)]
85    pub extra: Map<String, Value>,
86}
87
88// Deserializing from `&v` copies each field once, straight into the typed
89// line — no intermediate clone of the whole Value — and leaves `v` intact
90// for the fallback.
91impl From<Value> for Record {
92    fn from(v: Value) -> Self {
93        match v.get("type").and_then(Value::as_str) {
94            Some("session") => SessionHeader::deserialize(&v)
95                .map(Record::Session)
96                .unwrap_or(Record::Other(v)),
97            Some("message") => MessageEntry::deserialize(&v)
98                .map(Record::Message)
99                .unwrap_or(Record::Other(v)),
100            Some("custom_message") => CustomEntry::deserialize(&v)
101                .map(Record::Custom)
102                .unwrap_or(Record::Other(v)),
103            _ => Record::Other(v),
104        }
105    }
106}
107
108impl From<Record> for Value {
109    fn from(r: Record) -> Self {
110        fn tagged(line: impl Serialize, ty: &str) -> Value {
111            let mut v = serde_json::to_value(line).unwrap_or(Value::Null);
112            if let Value::Object(obj) = &mut v {
113                obj.insert("type".into(), Value::String(ty.into()));
114            }
115            v
116        }
117        match r {
118            Record::Session(s) => tagged(s, "session"),
119            Record::Message(m) => tagged(m, "message"),
120            Record::Custom(c) => tagged(c, "custom_message"),
121            Record::Other(v) => v,
122        }
123    }
124}
125
126impl Serialize for Record {
127    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
128        Value::from(self.clone()).serialize(s)
129    }
130}
131
132impl<'de> Deserialize<'de> for Record {
133    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
134        Ok(Record::from(Value::deserialize(d)?))
135    }
136}
137
138// ── codec ──────────────────────────────────────────────────────────────
139
140impl Codec for Pi {
141    fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
142        Ok(Transcript::new(
143            transcript.meta.clone(),
144            records_to_messages(&transcript.body, transcript.meta.timestamp),
145        ))
146    }
147
148    fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
149        Ok(Transcript::new(
150            transcript.meta.clone(),
151            messages_to_records(&transcript.meta, &transcript.body),
152        ))
153    }
154}
155
156impl TextCodec for Pi {
157    fn from_text(text: &str) -> Result<Transcript<Self>> {
158        let records = records_from_text(text);
159        Ok(Transcript::new(meta_from_records(&records), records))
160    }
161
162    fn to_text(transcript: &Transcript<Self>) -> Result<String> {
163        jsonl::render(&transcript.body)
164    }
165}
166
167/// pi native records → canonical messages. Shared with Campfire.
168pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime<Utc>) -> Vec<Message> {
169    let mut messages = Vec::new();
170    let mut bash_seq = 0usize;
171    for record in records {
172        match record {
173            Record::Message(entry) => {
174                let ts = entry
175                    .timestamp
176                    .as_deref()
177                    .and_then(parse_ts)
178                    .unwrap_or(fallback_ts);
179                let role = entry
180                    .message
181                    .get("role")
182                    .and_then(Value::as_str)
183                    .unwrap_or("");
184                let content = entry.message.get("content").unwrap_or(&Value::Null);
185                match role {
186                    "user" => {
187                        push_if_nonempty(
188                            &mut messages,
189                            Role::User,
190                            parse_user_content(content),
191                            ts,
192                        );
193                    }
194                    "assistant" => {
195                        let blocks = parse_assistant_content(content);
196                        if !blocks.is_empty() {
197                            messages.push(Message {
198                                role: Role::Assistant,
199                                content: blocks,
200                                timestamp: ts,
201                                model: entry
202                                    .message
203                                    .get("model")
204                                    .and_then(Value::as_str)
205                                    .map(String::from),
206                                stop_reason: entry
207                                    .message
208                                    .get("stopReason")
209                                    .and_then(Value::as_str)
210                                    .map(parse_stop_reason),
211                                usage: parse_usage(entry.message.get("usage")),
212                            });
213                        }
214                    }
215                    "toolResult" => {
216                        let tool_use_id = entry
217                            .message
218                            .get("toolCallId")
219                            .and_then(Value::as_str)
220                            .unwrap_or("")
221                            .to_string();
222                        messages.push(Message {
223                            role: Role::User,
224                            content: vec![Block::ToolResult {
225                                tool_use_id,
226                                content: parse_tool_result_content(content),
227                                is_error: entry
228                                    .message
229                                    .get("isError")
230                                    .and_then(Value::as_bool)
231                                    .unwrap_or(false),
232                            }],
233                            timestamp: ts,
234                            model: None,
235                            stop_reason: None,
236                            usage: None,
237                        });
238                    }
239                    "bashExecution" => {
240                        bash_seq += 1;
241                        push_bash_execution(&entry.message, ts, bash_seq, &mut messages);
242                    }
243                    // Unknown or missing roles carry no conversational turn.
244                    _ => {}
245                }
246            }
247            Record::Custom(c) => {
248                let ts = c
249                    .timestamp
250                    .as_deref()
251                    .and_then(parse_ts)
252                    .unwrap_or(fallback_ts);
253                push_if_nonempty(
254                    &mut messages,
255                    Role::User,
256                    parse_user_content(&c.content),
257                    ts,
258                );
259            }
260            // The header and bookkeeping lines carry no conversational turn.
261            Record::Session(_) | Record::Other(_) => {}
262        }
263    }
264    messages
265}
266
267/// Canonical messages → pi native records. Shared with Campfire.
268pub(crate) fn messages_to_records(meta: &Meta, messages: &[Message]) -> Vec<Record> {
269    let session_id = if meta.id.is_empty() {
270        Uuid::new_v4().to_string()
271    } else {
272        meta.id.clone()
273    };
274    let mut records = Vec::with_capacity(messages.len() + 1);
275    records.push(Record::Session(SessionHeader {
276        id: session_id.clone(),
277        timestamp: Some(meta.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)),
278        cwd: meta.cwd.clone(),
279        extra: Map::from_iter([("version".into(), json!(3))]),
280    }));
281
282    // toolCall id → pi tool name, so the matching toolResult carries it.
283    let mut tool_names: HashMap<String, String> = HashMap::new();
284    let mut parent_id: Option<String> = None;
285
286    for (i, msg) in messages.iter().enumerate() {
287        let ts_iso = msg.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true);
288        let ts_ms = msg.timestamp.timestamp_millis();
289        for (j, payload) in pi_payloads_for(msg, ts_ms, &mut tool_names)
290            .into_iter()
291            .enumerate()
292        {
293            let entry_id = short_id(&session_id, i, j);
294            records.push(Record::Message(MessageEntry {
295                id: entry_id.clone(),
296                parent_id: parent_id.clone(),
297                timestamp: Some(ts_iso.clone()),
298                message: payload,
299                extra: Map::new(),
300            }));
301            parent_id = Some(entry_id);
302        }
303    }
304    records
305}
306
307fn pi_payloads_for(
308    msg: &Message,
309    ts_ms: i64,
310    tool_names: &mut HashMap<String, String>,
311) -> Vec<Value> {
312    let mut out = Vec::new();
313    match msg.role {
314        Role::User => {
315            let mut content: Vec<Value> = Vec::new();
316            for block in &msg.content {
317                match block {
318                    Block::Text { text } => content.push(json!({"type": "text", "text": text})),
319                    Block::Image { source } => content.push(json!({
320                        "type": "image", "data": source.data, "mimeType": source.media_type,
321                    })),
322                    Block::Artifact { artifact } => {
323                        content.push(json!({"type": "text", "text": artifact.display_text()}));
324                    }
325                    Block::ToolResult {
326                        tool_use_id,
327                        content: result,
328                        is_error,
329                    } => {
330                        let tool_name = tool_names
331                            .get(tool_use_id)
332                            .cloned()
333                            .unwrap_or_else(|| "tool".to_string());
334                        out.push(json!({
335                            "role": "toolResult",
336                            "toolCallId": tool_use_id,
337                            "toolName": tool_name,
338                            "content": [{"type": "text", "text": tool_output_text(result)}],
339                            "isError": is_error,
340                            "timestamp": ts_ms,
341                        }));
342                    }
343                    // Not expressible in a pi user message.
344                    Block::Thinking { .. } | Block::ToolUse { .. } => {}
345                }
346            }
347            if !content.is_empty() {
348                out.push(json!({"role": "user", "content": content, "timestamp": ts_ms}));
349            }
350        }
351        Role::Assistant => {
352            let mut content: Vec<Value> = Vec::new();
353            for block in &msg.content {
354                match block {
355                    Block::Text { text } => content.push(json!({"type": "text", "text": text})),
356                    Block::Thinking { text, .. } => {
357                        content.push(json!({"type": "thinking", "thinking": text}));
358                    }
359                    Block::Artifact { artifact } => {
360                        content.push(json!({"type": "text", "text": artifact.display_text()}));
361                    }
362                    Block::ToolUse { id, tool } => {
363                        let (pi_name, pi_input) = denormalize_tool(tool);
364                        tool_names.insert(id.clone(), pi_name.clone());
365                        content.push(
366                            json!({"type": "toolCall", "id": id, "name": pi_name, "arguments": pi_input}),
367                        );
368                    }
369                    // Not expressible in a pi assistant message.
370                    Block::Image { .. } | Block::ToolResult { .. } => {}
371                }
372            }
373            // An assistant turn with no expressible blocks emits no record.
374            if !content.is_empty() {
375                let model = msg.model.clone().unwrap_or_default();
376                let (provider, api) = provider_api(&model);
377                out.push(json!({
378                    "role": "assistant",
379                    "content": content,
380                    "api": api,
381                    "provider": provider,
382                    "model": model,
383                    "usage": serialize_usage(msg.usage.as_ref()),
384                    "stopReason": stop_reason_str(msg.stop_reason.as_ref()),
385                    "timestamp": ts_ms,
386                }));
387            }
388        }
389    }
390    out
391}
392
393// ── store ──────────────────────────────────────────────────────────────
394
395/// Reads and writes pi-format sessions under a sessions root.
396#[derive(Debug, Clone)]
397pub struct PiStore {
398    pub sessions_dir: PathBuf,
399}
400
401impl PiStore {
402    pub fn new(sessions_dir: impl Into<PathBuf>) -> Self {
403        Self {
404            sessions_dir: sessions_dir.into(),
405        }
406    }
407
408    /// pi's default sessions root, honoring `PI_CODING_AGENT_*` overrides.
409    pub fn default_root() -> Option<Self> {
410        resolve_sessions_dir(".pi", "PI").map(Self::new)
411    }
412}
413
414impl Store for PiStore {
415    type H = Pi;
416    type Ref = PathBuf;
417
418    fn discover(&self) -> Result<Vec<Discovered<PathBuf>>> {
419        Ok(discover_format(&self.sessions_dir))
420    }
421
422    fn load(&self, reference: &PathBuf) -> Result<Transcript<Pi>> {
423        load_session(reference, Pi::from_text)
424    }
425
426    fn save(&self, transcript: &Transcript<Pi>) -> Result<Saved<PathBuf>> {
427        write_session(&self.sessions_dir, &transcript.meta, &transcript.body)
428    }
429
430    fn delete(&self, reference: &PathBuf) -> Result<()> {
431        Ok(std::fs::remove_file(reference)?)
432    }
433
434    fn fingerprints(&self, refs: &[PathBuf]) -> Result<HashMap<String, String>> {
435        Ok(file_fingerprints(refs))
436    }
437}
438
439// ── shared store helpers (also used by Campfire) ───────────────────────
440
441/// Read a pi/Campfire file and parse it with the given harness's `from_text`,
442/// filling an empty id from the filename. Shared by both stores' `load`.
443pub(crate) fn load_session<H, F>(path: &Path, from_text: F) -> Result<Transcript<H>>
444where
445    H: Harness,
446    F: Fn(&str) -> Result<Transcript<H>>,
447{
448    let mut transcript = from_text(&fs::read_to_string(path)?)?;
449    if transcript.meta.id.is_empty() {
450        transcript.meta.id = jsonl::file_id(path);
451    }
452    Ok(transcript)
453}
454
455pub(crate) fn discover_format(sessions_dir: &Path) -> Vec<Discovered<PathBuf>> {
456    let mut files = Vec::new();
457    if sessions_dir.is_dir() {
458        collect_jsonl(sessions_dir, &mut files);
459    }
460    files
461        .into_iter()
462        .filter_map(|path| {
463            // An unreadable file discovers nothing.
464            let text = fs::read_to_string(&path).ok()?;
465            // A pi file's first record must be a session header, else skip it.
466            let records = meta_scan(&text)?;
467            let mut meta = meta_from_records(&records);
468            if meta.id.is_empty() {
469                meta.id = jsonl::file_id(&path);
470            }
471            Some(Discovered {
472                meta,
473                reference: path,
474            })
475        })
476        .collect()
477}
478
479/// Parse one line straight into its typed record — no intermediate [`Value`]
480/// tree. Only lines outside the typed set pay a second parse into the
481/// preserved [`Record::Other`] `Value`: an unknown or non-string tag, or the
482/// rare known-tag line whose body fails its typed schema. Invalid JSON
483/// parses to `None` and is skipped, exactly as under [`jsonl::parse`].
484fn record_from_line(line: &str) -> Option<Record> {
485    let other = || serde_json::from_str::<Value>(line).ok().map(Record::Other);
486    match serde_json::from_str::<jsonl::TypeProbe>(line) {
487        // A failed probe isn't necessarily junk: a non-string `type` also
488        // fails it. The fallback keeps such lines whole and skips the rest.
489        Err(_) => other(),
490        Ok(probe) => match probe.kind.as_deref() {
491            Some("session") => serde_json::from_str(line)
492                .ok()
493                .map(Record::Session)
494                .or_else(other),
495            Some("message") => serde_json::from_str(line)
496                .ok()
497                .map(Record::Message)
498                .or_else(other),
499            Some("custom_message") => serde_json::from_str(line)
500                .ok()
501                .map(Record::Custom)
502                .or_else(other),
503            Some(_) | None => other(),
504        },
505    }
506}
507
508/// Every record in a session's text, typed one-pass per line. Shared with
509/// Campfire, whose files are pi-shaped.
510pub(crate) fn records_from_text(text: &str) -> Vec<Record> {
511    text.lines()
512        .filter(|line| !line.trim().is_empty())
513        .filter_map(record_from_line)
514        .collect()
515}
516
517/// Shallow scan for discovery: the first record decides whether this is a pi
518/// file at all (`None` when it is not a session header), and after it only
519/// the tiny meta-bearing line types are parsed — message payloads are never
520/// built. The result folds through [`meta_from_records`] exactly as the full
521/// parse would: the line types it skips contribute nothing to the fold.
522fn meta_scan(text: &str) -> Option<Vec<Record>> {
523    let mut lines = text.lines().filter(|line| !line.trim().is_empty());
524    // Lines that aren't valid JSON are not records at all (`jsonl::parse`
525    // skips them), so the first record is the first line that parses.
526    let first = lines
527        .by_ref()
528        .find_map(|line| serde_json::from_str::<Record>(line).ok())?;
529    match first {
530        Record::Session(header) => Some(
531            std::iter::once(Record::Session(header))
532                .chain(lines.filter_map(meta_line))
533                .collect(),
534        ),
535        // A file whose first record is not a session header is not a pi
536        // session.
537        Record::Message(_) | Record::Custom(_) | Record::Other(_) => None,
538    }
539}
540
541/// Parse one line only if its type can carry session metadata; conversational
542/// lines (the bulk of a session) are never parsed past the type tag.
543fn meta_line(line: &str) -> Option<Record> {
544    let probe: jsonl::TypeProbe = serde_json::from_str(line).ok()?;
545    match probe.kind.as_deref() {
546        Some("session" | "model_change" | "session_info") => {
547            serde_json::from_str::<Record>(line).ok()
548        }
549        // No other line type carries session metadata.
550        Some(_) | None => None,
551    }
552}
553
554pub(crate) fn write_session(
555    sessions_dir: &Path,
556    meta: &Meta,
557    records: &[Record],
558) -> Result<Saved<PathBuf>> {
559    let id = meta.id.clone();
560    // Callers are the pi and campfire stores; either way the id is the
561    // transcript's own.
562    super::checked_id_component("pi", &id)?;
563    let cwd = meta.cwd.as_deref().unwrap_or_default();
564    let dir = sessions_dir.join(encode_cwd(cwd));
565    fs::create_dir_all(&dir)?;
566    let file_ts = meta
567        .timestamp
568        .to_rfc3339_opts(SecondsFormat::Millis, true)
569        .replace([':', '.'], "-");
570    let path = dir.join(format!("{file_ts}_{id}.jsonl"));
571    fs::write(&path, jsonl::render(records)?)?;
572    Ok(Saved {
573        id,
574        reference: path,
575    })
576}
577
578pub(crate) fn meta_from_records(records: &[Record]) -> Meta {
579    let mut meta = Meta {
580        id: String::new(),
581        timestamp: Utc::now(),
582        cwd: None,
583        git_branch: None,
584        title: None,
585        cli_version: None,
586        model: None,
587    };
588    for record in records {
589        match record {
590            Record::Session(s) => {
591                meta.id.clone_from(&s.id);
592                meta.cwd.clone_from(&s.cwd);
593                if let Some(ts) = s.timestamp.as_deref().and_then(parse_ts) {
594                    meta.timestamp = ts;
595                }
596            }
597            Record::Other(v) => match v.get("type").and_then(Value::as_str) {
598                // Latest model_change wins; model can switch mid-session.
599                Some("model_change") => {
600                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
601                        meta.model = Some(m.to_string());
602                    }
603                }
604                Some("session_info") => {
605                    meta.title = v
606                        .get("name")
607                        .and_then(Value::as_str)
608                        .map(str::trim)
609                        .filter(|s| !s.is_empty())
610                        .map(String::from);
611                }
612                // Other bookkeeping types carry no session metadata.
613                _ => {}
614            },
615            // Conversational lines carry no session metadata.
616            Record::Message(_) | Record::Custom(_) => {}
617        }
618    }
619    meta
620}
621
622/// pi/Campfire sessions-dir resolution: `<PREFIX>_CODING_AGENT_SESSION_DIR`
623/// wins, then `<PREFIX>_CODING_AGENT_DIR` + `/sessions`, then
624/// `~/<config_dir>/agent/sessions`.
625pub(crate) fn resolve_sessions_dir(config_dir: &str, env_prefix: &str) -> Option<PathBuf> {
626    let expand = |raw: String| -> Option<PathBuf> {
627        if raw.is_empty() {
628            None
629        } else if let Some(rest) = raw.strip_prefix('~') {
630            home().map(|h| h.join(rest.trim_start_matches(['/', '\\'])))
631        } else {
632            Some(PathBuf::from(raw))
633        }
634    };
635    let env_dir = |suffix: &str| {
636        std::env::var(format!("{env_prefix}_CODING_AGENT_{suffix}"))
637            .ok()
638            .and_then(&expand)
639    };
640    env_dir("SESSION_DIR").or_else(|| {
641        env_dir("DIR")
642            .or_else(|| home().map(|h| h.join(config_dir).join("agent")))
643            .map(|agent_dir| agent_dir.join("sessions"))
644    })
645}
646
647// ── content parsing ────────────────────────────────────────────────────
648
649fn parse_user_content(content: &Value) -> Vec<Block> {
650    match content {
651        Value::String(s) => {
652            if s.trim().is_empty() {
653                Vec::new()
654            } else {
655                vec![Block::Text { text: s.clone() }]
656            }
657        }
658        Value::Array(arr) => arr
659            .iter()
660            .filter_map(|b| match b.get("type").and_then(Value::as_str) {
661                Some("text") => {
662                    let text = b.get("text")?.as_str()?;
663                    (!text.trim().is_empty()).then(|| Block::Text {
664                        text: text.to_string(),
665                    })
666                }
667                Some("image") => parse_image(b).map(|source| Block::Image { source }),
668                // Unknown or untagged blocks carry no user content.
669                _ => None,
670            })
671            .collect(),
672        // Other JSON shapes carry no user content.
673        Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => Vec::new(),
674    }
675}
676
677fn parse_assistant_content(content: &Value) -> Vec<Block> {
678    // Assistant content is always a block array; anything else carries none.
679    content.as_array().map_or_else(Vec::new, |arr| {
680        arr.iter()
681            .filter_map(|b| match b.get("type").and_then(Value::as_str) {
682                Some("text") => {
683                    let text = b.get("text")?.as_str()?;
684                    (!text.trim().is_empty()).then(|| Block::Text {
685                        text: text.to_string(),
686                    })
687                }
688                Some("thinking") => {
689                    let thinking = b.get("thinking")?.as_str()?;
690                    (!thinking.trim().is_empty()).then(|| Block::Thinking {
691                        text: thinking.to_string(),
692                        signature: None,
693                        encrypted: None,
694                    })
695                }
696                Some("toolCall") => {
697                    let id = b
698                        .get("id")
699                        .and_then(Value::as_str)
700                        .unwrap_or("")
701                        .to_string();
702                    let raw_name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
703                    let raw_input = b
704                        .get("arguments")
705                        .cloned()
706                        .unwrap_or(Value::Object(Map::new()));
707                    let (name, input) = normalize_tool(raw_name, raw_input);
708                    Some(Block::ToolUse {
709                        id,
710                        tool: Tool::from_canonical(&name, input),
711                    })
712                }
713                // Unknown or untagged blocks carry no assistant content.
714                _ => None,
715            })
716            .collect()
717    })
718}
719
720fn parse_tool_result_content(content: &Value) -> ToolOutput {
721    match content {
722        Value::String(s) => ToolOutput::Text(s.clone()),
723        // An all-text block array flattens to plain text; one with images
724        // keeps the block structure as JSON.
725        Value::Array(arr)
726            if !arr
727                .iter()
728                .any(|b| b.get("type").and_then(Value::as_str) == Some("image")) =>
729        {
730            let text = arr
731                .iter()
732                .filter_map(|b| {
733                    (b.get("type").and_then(Value::as_str) == Some("text"))
734                        .then(|| b.get("text").and_then(Value::as_str))
735                        .flatten()
736                })
737                .collect::<Vec<_>>()
738                .join("\n");
739            ToolOutput::Text(text)
740        }
741        Value::Array(arr) => {
742            let blocks: Vec<Value> = arr
743                .iter()
744                .filter_map(|b| match b.get("type").and_then(Value::as_str) {
745                    Some("text") => Some(json!({"type": "text", "text": b.get("text")?.as_str()?})),
746                    Some("image") => {
747                        let s = parse_image(b)?;
748                        Some(json!({"type": "image", "source": {
749                            "type": s.source_type, "media_type": s.media_type, "data": s.data,
750                        }}))
751                    }
752                    // Unknown or untagged blocks carry no tool output.
753                    _ => None,
754                })
755                .collect();
756            ToolOutput::Json(Value::Array(blocks))
757        }
758        other => ToolOutput::Json(other.clone()),
759    }
760}
761
762fn parse_image(block: &Value) -> Option<ImageSource> {
763    Some(ImageSource {
764        source_type: "base64".to_string(),
765        media_type: block
766            .get("mimeType")
767            .and_then(Value::as_str)
768            .unwrap_or("image/png")
769            .to_string(),
770        data: block.get("data").and_then(Value::as_str)?.to_string(),
771    })
772}
773
774fn parse_usage(usage: Option<&Value>) -> Option<Usage> {
775    let usage = usage?;
776    let input = usage.get("input").and_then(Value::as_u64).unwrap_or(0);
777    let output = usage.get("output").and_then(Value::as_u64).unwrap_or(0);
778    let cache_read = usage.get("cacheRead").and_then(Value::as_u64);
779    let cache_write = usage.get("cacheWrite").and_then(Value::as_u64);
780    // An all-zero usage carries no information.
781    let has_tokens =
782        input != 0 || output != 0 || cache_read.unwrap_or(0) != 0 || cache_write.unwrap_or(0) != 0;
783    has_tokens.then_some(Usage {
784        input_tokens: input,
785        output_tokens: output,
786        cache_read_input_tokens: cache_read,
787        cache_creation_input_tokens: cache_write,
788    })
789}
790
791fn serialize_usage(usage: Option<&Usage>) -> Value {
792    let input = usage.map_or(0, |u| u.input_tokens);
793    let output = usage.map_or(0, |u| u.output_tokens);
794    json!({
795        "input": input,
796        "output": output,
797        "cacheRead": usage.and_then(|u| u.cache_read_input_tokens).unwrap_or(0),
798        "cacheWrite": usage.and_then(|u| u.cache_creation_input_tokens).unwrap_or(0),
799        "totalTokens": input + output,
800        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
801    })
802}
803
804fn push_bash_execution(msg: &Value, ts: DateTime<Utc>, seq: usize, out: &mut Vec<Message>) {
805    let excluded = msg
806        .get("excludeFromContext")
807        .and_then(Value::as_bool)
808        .unwrap_or(false);
809    match msg.get("command").and_then(Value::as_str) {
810        // pi excludes `!!`-prefixed runs from LLM context; mirror that.
811        _ if excluded => {}
812        // A run without a command carries no turn.
813        None | Some("") => {}
814        Some(command) => {
815            let output = msg.get("output").and_then(Value::as_str).unwrap_or("");
816            let is_error = msg
817                .get("exitCode")
818                .and_then(Value::as_i64)
819                .is_some_and(|c| c != 0);
820            let call_id = format!("bash_exec_{seq}");
821            out.push(Message {
822                role: Role::Assistant,
823                content: vec![Block::ToolUse {
824                    id: call_id.clone(),
825                    tool: Tool::Bash {
826                        command: command.to_string(),
827                        workdir: None,
828                        timeout_ms: None,
829                        description: None,
830                        run_in_background: false,
831                    },
832                }],
833                timestamp: ts,
834                model: None,
835                stop_reason: None,
836                usage: None,
837            });
838            out.push(Message {
839                role: Role::User,
840                content: vec![Block::ToolResult {
841                    tool_use_id: call_id,
842                    content: ToolOutput::Text(output.to_string()),
843                    is_error,
844                }],
845                timestamp: ts,
846                model: None,
847                stop_reason: None,
848                usage: None,
849            });
850        }
851    }
852}
853
854// ── tool normalization ─────────────────────────────────────────────────
855
856/// pi tool name + input → canonical (Claude) name + input.
857fn normalize_tool(tool: &str, input: Value) -> (String, Value) {
858    match tool {
859        // MCP tool names are identical on both sides.
860        t if t.starts_with("mcp__") => (t.to_string(), input),
861        "bash" => ("Bash".to_string(), input),
862        "read" => (
863            "Read".to_string(),
864            rename_keys(input, &[("path", "file_path")]),
865        ),
866        "write" => (
867            "Write".to_string(),
868            rename_keys(input, &[("path", "file_path")]),
869        ),
870        "edit" => normalize_edit(input),
871        "grep" => ("Grep".to_string(), input),
872        "find" => ("Glob".to_string(), input),
873        "ls" => ("LS".to_string(), input),
874        other => (other.to_string(), input),
875    }
876}
877
878fn normalize_edit(input: Value) -> (String, Value) {
879    match input {
880        Value::Object(mut obj) => {
881            let file_path = obj.remove("path");
882            if let Some(Value::Array(edits)) = obj.remove("edits") {
883                let mapped: Vec<Value> = edits
884                    .into_iter()
885                    .map(|e| {
886                        rename_keys(e, &[("oldText", "old_string"), ("newText", "new_string")])
887                    })
888                    .collect();
889                if mapped.len() == 1 {
890                    let mut out = Map::new();
891                    if let Some(fp) = file_path {
892                        out.insert("file_path".to_string(), fp);
893                    }
894                    if let Some(old) = mapped[0].get("old_string") {
895                        out.insert("old_string".to_string(), old.clone());
896                    }
897                    if let Some(new) = mapped[0].get("new_string") {
898                        out.insert("new_string".to_string(), new.clone());
899                    }
900                    ("Edit".to_string(), Value::Object(out))
901                } else {
902                    let mut out = Map::new();
903                    if let Some(fp) = file_path {
904                        out.insert("file_path".to_string(), fp);
905                    }
906                    out.insert("edits".to_string(), Value::Array(mapped));
907                    ("MultiEdit".to_string(), Value::Object(out))
908                }
909            } else {
910                // Missing or non-array `edits`: a plain Edit, path renamed.
911                if let Some(fp) = file_path {
912                    obj.insert("file_path".to_string(), fp);
913                }
914                ("Edit".to_string(), Value::Object(obj))
915            }
916        }
917        // Non-object arguments pass through unchanged.
918        other => ("Edit".to_string(), other),
919    }
920}
921
922/// Canonical [`Tool`] → pi tool name + arguments (inverse of `normalize_tool`).
923fn denormalize_tool(tool: &Tool) -> (String, Value) {
924    let (name, input) = tool.to_canonical();
925    match name.as_str() {
926        // MCP tool names are identical on both sides.
927        n if n.starts_with("mcp__") => (n.to_string(), input),
928        "Bash" => ("bash".to_string(), input),
929        "Read" => (
930            "read".to_string(),
931            rename_keys(input, &[("file_path", "path")]),
932        ),
933        "Write" => (
934            "write".to_string(),
935            rename_keys(input, &[("file_path", "path")]),
936        ),
937        "Edit" => match input {
938            Value::Object(mut obj) => {
939                let path = obj.remove("file_path");
940                let old = obj.remove("old_string").unwrap_or_default();
941                let new = obj.remove("new_string").unwrap_or_default();
942                let mut out = Map::new();
943                if let Some(p) = path {
944                    out.insert("path".to_string(), p);
945                }
946                out.insert(
947                    "edits".to_string(),
948                    json!([{"oldText": old, "newText": new}]),
949                );
950                ("edit".to_string(), Value::Object(out))
951            }
952            // Non-object arguments pass through unchanged.
953            other => ("edit".to_string(), other),
954        },
955        "MultiEdit" => match input {
956            Value::Object(mut obj) => {
957                let path = obj.remove("file_path");
958                let edits = match obj.remove("edits") {
959                    Some(Value::Array(a)) => a,
960                    // Missing or non-array `edits` maps to an empty list.
961                    None | Some(_) => Vec::new(),
962                };
963                let mapped: Vec<Value> = edits
964                    .into_iter()
965                    .map(|e| {
966                        rename_keys(e, &[("old_string", "oldText"), ("new_string", "newText")])
967                    })
968                    .collect();
969                let mut out = Map::new();
970                if let Some(p) = path {
971                    out.insert("path".to_string(), p);
972                }
973                out.insert("edits".to_string(), Value::Array(mapped));
974                ("edit".to_string(), Value::Object(out))
975            }
976            // Non-object arguments pass through unchanged.
977            other => ("edit".to_string(), other),
978        },
979        "Grep" => ("grep".to_string(), input),
980        "Glob" => ("find".to_string(), input),
981        "LS" => ("ls".to_string(), input),
982        other => (other.to_string(), input),
983    }
984}
985
986// ── small helpers ──────────────────────────────────────────────────────
987
988fn parse_stop_reason(s: &str) -> StopReason {
989    match s {
990        "stop" => StopReason::EndTurn,
991        "length" => StopReason::MaxTokens,
992        "toolUse" => StopReason::ToolUse,
993        "error" => StopReason::Error,
994        "aborted" => StopReason::Aborted,
995        other => StopReason::Other(other.to_string()),
996    }
997}
998
999fn stop_reason_str(r: Option<&StopReason>) -> &'static str {
1000    match r {
1001        Some(StopReason::MaxTokens) => "length",
1002        Some(StopReason::ToolUse) => "toolUse",
1003        Some(StopReason::Error) => "error",
1004        Some(StopReason::Aborted) => "aborted",
1005        // pi's vocabulary ends here: a normal end of turn, a stop sequence,
1006        // an unknown reason, or no reason at all all render as "stop".
1007        Some(StopReason::EndTurn | StopReason::StopSequence | StopReason::Other(_)) | None => {
1008            "stop"
1009        }
1010    }
1011}
1012
1013/// Best-effort `(provider, api)` from a model id — internally plausible for the
1014/// historical record; pi uses the live provider on resume.
1015fn provider_api(model: &str) -> (&'static str, &'static str) {
1016    let m = model.to_ascii_lowercase();
1017    if m.starts_with("gpt") || m.starts_with("o1") || m.starts_with("o3") || m.contains("codex") {
1018        ("openai", "openai-responses")
1019    } else if m.starts_with("gemini") {
1020        ("google", "google-generative-ai")
1021    } else {
1022        ("anthropic", "anthropic-messages")
1023    }
1024}
1025
1026fn tool_output_text(out: &ToolOutput) -> String {
1027    match out {
1028        ToolOutput::Text(s) => s.clone(),
1029        ToolOutput::Json(v) => v.to_string(),
1030    }
1031}
1032
1033fn rename_keys(input: Value, renames: &[(&str, &str)]) -> Value {
1034    match input {
1035        Value::Object(mut obj) => {
1036            for (from, to) in renames {
1037                if from != to
1038                    && let Some(value) = obj.remove(*from)
1039                {
1040                    obj.insert((*to).to_string(), value);
1041                }
1042            }
1043            Value::Object(obj)
1044        }
1045        // Non-object inputs have no keys to rename.
1046        other => other,
1047    }
1048}
1049
1050fn push_if_nonempty(out: &mut Vec<Message>, role: Role, content: Vec<Block>, ts: DateTime<Utc>) {
1051    if !content.is_empty() {
1052        out.push(Message {
1053            role,
1054            content,
1055            timestamp: ts,
1056            model: None,
1057            stop_reason: None,
1058            usage: None,
1059        });
1060    }
1061}
1062
1063fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
1064    s.parse::<DateTime<Utc>>().ok()
1065}
1066
1067/// Deterministic 8-char pi entry id (pi truncates uuids); pure function of the
1068/// session id and the message/payload index.
1069fn short_id(session_id: &str, i: usize, j: usize) -> String {
1070    const NS: Uuid = Uuid::from_bytes([
1071        0x70, 0x69, 0x2d, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x2d, 0x69, 0x64, 0x2d, 0x6e, 0x73, 0x21,
1072        0x21,
1073    ]);
1074    let full = Uuid::new_v5(&NS, format!("{session_id}:{i}:{j}").as_bytes()).to_string();
1075    full[..8].to_string()
1076}
1077
1078fn collect_jsonl(dir: &Path, out: &mut Vec<PathBuf>) {
1079    // An unreadable directory contributes no files.
1080    if let Ok(entries) = fs::read_dir(dir) {
1081        for entry in entries.flatten() {
1082            let path = entry.path();
1083            // `file_type` doesn't follow symlinks: a link pointing back at an
1084            // ancestor would otherwise recurse forever. Symlinked directories
1085            // are skipped; symlinked session files still list.
1086            if entry.file_type().is_ok_and(|t| t.is_dir()) {
1087                collect_jsonl(&path, out);
1088            } else if path.extension().is_some_and(|e| e == "jsonl") {
1089                out.push(path);
1090            }
1091        }
1092    }
1093}
1094
1095/// pi's cwd → dir-name encoding: strip the leading separator, replace
1096/// `/ \ :` with `-`, wrap in `--…--`.
1097fn encode_cwd(cwd: &str) -> String {
1098    let body: String = cwd
1099        .trim_start_matches(['/', '\\'])
1100        .chars()
1101        .map(|c| {
1102            if matches!(c, '/' | '\\' | ':') {
1103                '-'
1104            } else {
1105                c
1106            }
1107        })
1108        .collect();
1109    format!("--{body}--")
1110}
1111
1112fn file_fingerprints(refs: &[PathBuf]) -> HashMap<String, String> {
1113    refs.iter()
1114        .map(|path| {
1115            let fp = fs::metadata(path)
1116                .ok()
1117                .and_then(|m| {
1118                    let len = m.len();
1119                    m.modified()
1120                        .ok()
1121                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1122                        .map(|d| format!("{}:{len}", d.as_nanos()))
1123                })
1124                .unwrap_or_default();
1125            (path.to_string_lossy().into_owned(), fp)
1126        })
1127        .collect()
1128}
1129
1130fn home() -> Option<PathBuf> {
1131    super::home_dir()
1132}