Skip to main content

skilllite_executor/
transcript.rs

1//! Transcript store: *.jsonl append-only, tree structure.
2//!
3//! Entry types: message, tool_call, tool_result, custom_message, custom, compaction, branch_summary.
4//!
5//! Time-based segmentation (aligned with OpenClaw): files are named
6//! `{session_key}-YYYY-MM-DD.jsonl` so each day gets a new file. Legacy
7//! `{session_key}.jsonl` without date is still supported for backward compat.
8
9use 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    /// Tool call request - independent entry for complete traceability (aligned with OpenAI Agents SDK tracing)
32    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    /// Tool execution result - independent entry for complete traceability
41    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
95/// Append an entry to transcript file. Creates file and parent dir if needed.
96pub 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
111/// Read all entries from transcript (for context building). Returns entries in order.
112pub 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
132/// Ensure transcript has session header. Call once when creating new transcript.
133pub 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(()); // already has header
143            }
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
163/// Date string for today (YYYY-MM-DD), local timezone. Used for log segmentation.
164fn date_today() -> String {
165    chrono::Local::now().format("%Y-%m-%d").to_string()
166}
167
168/// Path for session's transcript file. With date segmentation: `{session_key}-YYYY-MM-DD.jsonl`.
169pub 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
178/// Path for today's transcript file (used for append).
179pub fn transcript_path_today(transcripts_dir: &Path, session_key: &str) -> PathBuf {
180    transcript_path_for_session(transcripts_dir, session_key, None)
181}
182
183/// List all transcript files for a session, sorted by date (legacy first, then YYYY-MM-DD).
184pub 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(); // legacy, treat as oldest
227    }
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
236/// Read all entries from all transcript files for a session (merged in date order).
237pub 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}