1use 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#[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#[derive(Debug, Clone, PartialEq)]
42pub enum Record {
43 Session(SessionHeader),
44 Message(MessageEntry),
45 Custom(CustomEntry),
46 Other(Value),
47}
48
49#[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#[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#[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
88impl 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
138impl 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
167pub(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 _ => {}
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 Record::Session(_) | Record::Other(_) => {}
262 }
263 }
264 messages
265}
266
267pub(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 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 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 Block::Image { .. } | Block::ToolResult { .. } => {}
371 }
372 }
373 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#[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 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
439pub(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 let text = fs::read_to_string(&path).ok()?;
465 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
479fn 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 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
508pub(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
517fn meta_scan(text: &str) -> Option<Vec<Record>> {
523 let mut lines = text.lines().filter(|line| !line.trim().is_empty());
524 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 Record::Message(_) | Record::Custom(_) | Record::Other(_) => None,
538 }
539}
540
541fn 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 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 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 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 _ => {}
614 },
615 Record::Message(_) | Record::Custom(_) => {}
617 }
618 }
619 meta
620}
621
622pub(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
647fn 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 _ => None,
670 })
671 .collect(),
672 Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => Vec::new(),
674 }
675}
676
677fn parse_assistant_content(content: &Value) -> Vec<Block> {
678 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 _ => 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 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 _ => 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 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 _ if excluded => {}
812 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
854fn normalize_tool(tool: &str, input: Value) -> (String, Value) {
858 match tool {
859 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 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 other => ("Edit".to_string(), other),
919 }
920}
921
922fn denormalize_tool(tool: &Tool) -> (String, Value) {
924 let (name, input) = tool.to_canonical();
925 match name.as_str() {
926 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 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 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 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
986fn 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 Some(StopReason::EndTurn | StopReason::StopSequence | StopReason::Other(_)) | None => {
1008 "stop"
1009 }
1010 }
1011}
1012
1013fn 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 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
1067fn 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 if let Ok(entries) = fs::read_dir(dir) {
1081 for entry in entries.flatten() {
1082 let path = entry.path();
1083 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
1095fn 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}