1use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::fs::OpenOptions;
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum TranscriptEntry {
18 Session {
19 id: String,
20 cwd: Option<String>,
21 timestamp: String,
22 },
23 Message {
24 id: String,
25 parent_id: Option<String>,
26 role: String,
27 content: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 tool_calls: Option<serde_json::Value>,
30 },
31 ToolCall {
33 id: String,
34 parent_id: Option<String>,
35 tool_call_id: String,
36 name: String,
37 arguments: String,
38 timestamp: String,
39 },
40 ToolResult {
42 id: String,
43 parent_id: Option<String>,
44 tool_call_id: String,
45 name: String,
46 result: String,
47 is_error: bool,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 elapsed_ms: Option<u64>,
50 timestamp: String,
51 },
52 CustomMessage {
53 id: String,
54 parent_id: Option<String>,
55 #[serde(flatten)]
56 data: serde_json::Value,
57 },
58 Custom {
59 id: String,
60 parent_id: Option<String>,
61 kind: String,
62 #[serde(flatten)]
63 data: serde_json::Value,
64 },
65 Compaction {
66 id: String,
67 parent_id: Option<String>,
68 first_kept_entry_id: String,
69 tokens_before: u64,
70 summary: Option<String>,
71 },
72 BranchSummary {
73 id: String,
74 parent_id: Option<String>,
75 #[serde(flatten)]
76 data: serde_json::Value,
77 },
78}
79
80impl TranscriptEntry {
81 pub fn entry_id(&self) -> Option<&str> {
82 match self {
83 Self::Session { id, .. } => Some(id),
84 Self::Message { id, .. } => Some(id),
85 Self::ToolCall { id, .. } => Some(id),
86 Self::ToolResult { id, .. } => Some(id),
87 Self::CustomMessage { id, .. } => Some(id),
88 Self::Custom { id, .. } => Some(id),
89 Self::Compaction { id, .. } => Some(id),
90 Self::BranchSummary { id, .. } => Some(id),
91 }
92 }
93}
94
95pub fn append_entry(transcript_path: &Path, entry: &TranscriptEntry) -> Result<()> {
97 if let Some(parent) = transcript_path.parent() {
98 std::fs::create_dir_all(parent)?;
99 }
100 let mut file = OpenOptions::new()
101 .create(true)
102 .append(true)
103 .open(transcript_path)
104 .with_context(|| format!("Failed to open transcript: {}", transcript_path.display()))?;
105 let line = serde_json::to_string(entry)?;
106 writeln!(file, "{}", line)?;
107 file.sync_all().context("transcript flush")?;
108 Ok(())
109}
110
111pub fn read_entries(transcript_path: &Path) -> Result<Vec<TranscriptEntry>> {
113 if !transcript_path.exists() {
114 return Ok(Vec::new());
115 }
116 let file = std::fs::File::open(transcript_path)
117 .with_context(|| format!("Failed to open transcript: {}", transcript_path.display()))?;
118 let reader = BufReader::new(file);
119 let mut entries = Vec::new();
120 for line in reader.lines() {
121 let line = line?;
122 let line = line.trim();
123 if line.is_empty() {
124 continue;
125 }
126 let entry: TranscriptEntry = serde_json::from_str(line)?;
127 entries.push(entry);
128 }
129 Ok(entries)
130}
131
132pub fn ensure_session_header(
134 transcript_path: &Path,
135 session_id: &str,
136 cwd: Option<&str>,
137) -> Result<()> {
138 if transcript_path.exists() {
139 let entries = read_entries(transcript_path)?;
140 if !entries.is_empty() {
141 if let TranscriptEntry::Session { .. } = &entries[0] {
142 return Ok(()); }
144 }
145 }
146 let header = TranscriptEntry::Session {
147 id: session_id.to_string(),
148 cwd: cwd.map(|s| s.to_string()),
149 timestamp: timestamp_now(),
150 };
151 append_entry(transcript_path, &header)
152}
153
154fn timestamp_now() -> String {
155 use std::time::{SystemTime, UNIX_EPOCH};
156 let secs = SystemTime::now()
157 .duration_since(UNIX_EPOCH)
158 .map(|d| d.as_secs())
159 .unwrap_or(0);
160 format!("{}", secs)
161}
162
163fn date_today() -> String {
165 chrono::Local::now().format("%Y-%m-%d").to_string()
166}
167
168pub fn transcript_path_for_session(
170 transcripts_dir: &Path,
171 session_key: &str,
172 date: Option<&str>,
173) -> PathBuf {
174 let date_str = date.map(|s| s.to_string()).unwrap_or_else(date_today);
175 transcripts_dir.join(format!("{}-{}.jsonl", session_key, date_str))
176}
177
178pub fn transcript_path_today(transcripts_dir: &Path, session_key: &str) -> PathBuf {
180 transcript_path_for_session(transcripts_dir, session_key, None)
181}
182
183pub fn list_transcript_files(transcripts_dir: &Path, session_key: &str) -> Result<Vec<PathBuf>> {
185 let legacy = transcripts_dir.join(format!("{}.jsonl", session_key));
186 let mut files = Vec::new();
187 if legacy.exists() {
188 files.push(legacy);
189 }
190 if !transcripts_dir.exists() {
191 return Ok(files);
192 }
193 let entries = std::fs::read_dir(transcripts_dir).with_context(|| {
194 format!(
195 "Failed to read transcripts dir: {}",
196 transcripts_dir.display()
197 )
198 })?;
199 for e in entries {
200 let e = e?;
201 let path = e.path();
202 if let Some(name) = path.file_name() {
203 let name = name.to_string_lossy();
204 if name.starts_with(session_key)
205 && name.ends_with(".jsonl")
206 && name != format!("{}.jsonl", session_key)
207 {
208 files.push(path);
209 }
210 }
211 }
212 files.sort_by(|a, b| {
213 let date_a = extract_date_from_path(a, session_key);
214 let date_b = extract_date_from_path(b, session_key);
215 date_a.cmp(&date_b)
216 });
217 Ok(files)
218}
219
220fn extract_date_from_path(path: &Path, session_key: &str) -> String {
221 let name = path
222 .file_stem()
223 .map(|s| s.to_string_lossy())
224 .unwrap_or_default();
225 if name == session_key {
226 return "0000-00-00".to_string(); }
228 let prefix = format!("{}-", session_key);
229 if name.starts_with(&prefix) {
230 name.trim_start_matches(&prefix).to_string()
231 } else {
232 "0000-00-00".to_string()
233 }
234}
235
236pub fn read_entries_for_session(
238 transcripts_dir: &Path,
239 session_key: &str,
240) -> Result<Vec<TranscriptEntry>> {
241 let paths = list_transcript_files(transcripts_dir, session_key)?;
242 let mut all = Vec::new();
243 for p in paths {
244 let entries = read_entries(&p)?;
245 all.extend(entries);
246 }
247 Ok(all)
248}