Skip to main content

edda_transcript/
pi.rs

1//! Pi session transcript discovery, delta ingestion, and ledger normalization (GH-577).
2//!
3//! Pi persists its transcripts to:
4//! `~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<session-id>.jsonl`
5//! (or to a custom directory when `--session-dir` is provided).
6//!
7//! This module provides:
8//! 1. `find_pi_session_file`: locates the session file by matching session ID in filename or header.
9//! 2. `ingest_pi_transcript_delta`: cursor-based incremental ingestion into `transcripts/{session_id}.jsonl`
10//!    and `ledger/{session_id}.jsonl` (normalizing tool calls and failures so `digest` can summarize it),
11//!    plus updating `state/usage.json`.
12
13use crate::cursor::TranscriptCursor;
14use serde::{Deserialize, Serialize};
15use std::fs;
16use std::io::{Read, Seek, SeekFrom, Write};
17use std::path::{Path, PathBuf};
18
19/// Ingestion statistics for a Pi session delta.
20#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
21pub struct PiIngestStats {
22    pub records_read: usize,
23    pub records_kept: usize,
24    pub tool_calls: usize,
25    pub tool_failures: usize,
26    pub user_prompts: usize,
27    pub bytes_read: u64,
28    pub from_offset: u64,
29    pub to_offset: u64,
30    pub model: String,
31    pub input_tokens: u64,
32    pub output_tokens: u64,
33    pub cache_read_tokens: u64,
34    pub cache_creation_tokens: u64,
35    pub cost_usd: Option<f64>,
36}
37
38/// Compute the default Pi session directory for a given working directory.
39/// Encodes `cwd` into `--<escaped_path>--` under `~/.pi/agent/sessions/`,
40/// strictly matching `@earendil-works/pi-coding-agent`'s `getDefaultSessionDirPath`.
41pub fn pi_session_dir_for_cwd(cwd: &Path) -> Option<PathBuf> {
42    let resolved = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
43    let s = resolved.to_string_lossy();
44    let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
45    let trimmed = s.trim_start_matches(['/', '\\']);
46    let mut safe = String::with_capacity(trimmed.len() + 4);
47    safe.push_str("--");
48    for c in trimmed.chars() {
49        if c == '/' || c == '\\' || c == ':' {
50            safe.push('-');
51        } else {
52            safe.push(c);
53        }
54    }
55    safe.push_str("--");
56    let home = edda_core::paths::home_dir()?;
57    Some(home.join(".pi").join("agent").join("sessions").join(safe))
58}
59
60/// Find the Pi session JSONL file for a given session ID.
61///
62/// If `session_dir` is provided, searches there.
63/// Otherwise, resolves the default directory via [`pi_session_dir_for_cwd`].
64pub fn find_pi_session_file(
65    cwd: &Path,
66    session_id: &str,
67    session_dir: Option<&Path>,
68) -> Option<PathBuf> {
69    let dir = match session_dir {
70        Some(d) => d.to_path_buf(),
71        None => pi_session_dir_for_cwd(cwd)?,
72    };
73
74    if !dir.exists() {
75        return None;
76    }
77
78    let entries = fs::read_dir(&dir).ok()?;
79    let suffix = format!("_{session_id}.jsonl");
80    let exact = format!("{session_id}.jsonl");
81
82    let mut matches = Vec::new();
83    for entry in entries.flatten() {
84        let path = entry.path();
85        if !path.is_file() {
86            continue;
87        }
88        let fname = entry.file_name().to_string_lossy().to_string();
89        if !fname.ends_with(".jsonl") {
90            continue;
91        }
92
93        if fname == exact || fname.ends_with(&suffix) {
94            matches.push(path);
95            continue;
96        }
97
98        // Header check: if filename doesn't match suffix, inspect the first line
99        if let Ok(file) = fs::File::open(&path) {
100            use std::io::BufRead;
101            let mut reader = std::io::BufReader::new(file);
102            let mut first_line = String::new();
103            if reader.read_line(&mut first_line).is_ok() {
104                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&first_line) {
105                    if v.get("type").and_then(|t| t.as_str()) == Some("session")
106                        && v.get("id").and_then(|id| id.as_str()) == Some(session_id)
107                    {
108                        matches.push(path);
109                    }
110                }
111            }
112        }
113    }
114
115    // If multiple matches exist, pick the most recently modified
116    matches.sort_by_key(|p| {
117        fs::metadata(p)
118            .and_then(|m| m.modified())
119            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
120    });
121    matches.pop()
122}
123
124fn now_rfc3339() -> String {
125    let now = time::OffsetDateTime::now_utc();
126    now.format(&time::format_description::well_known::Rfc3339)
127        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
128}
129
130fn normalize_tool_name(name: &str) -> &str {
131    match name {
132        "bash" | "terminal" | "shell" => "Bash",
133        "edit" | "edit_file" | "file_edit" => "Edit",
134        "write" | "write_file" | "file_write" => "Write",
135        other => other,
136    }
137}
138
139const DEFAULT_MAX_BYTES: u64 = 8 * 1024 * 1024; // 8MB
140
141/// Ingest a Pi session transcript file incrementally into `project_dir`.
142///
143/// 1. Reads new lines using a session-specific cursor (`pi_transcript_cursor.{session_id}.json`).
144/// 2. Appends raw transcript lines to `transcripts/{session_id}.jsonl`.
145/// 3. Normalizes tool calls, tool results, and user prompts into `ledger/{session_id}.jsonl`.
146/// 4. Updates `state/usage.json` with observed model and token totals.
147#[allow(clippy::too_many_lines)] // 367 lines at #779; split tracked in none
148pub fn ingest_pi_transcript_delta(
149    project_dir: &Path,
150    session_id: &str,
151    cwd: &Path,
152    transcript_path: &Path,
153) -> anyhow::Result<PiIngestStats> {
154    let state_dir = project_dir.join("state");
155    let ledger_dir = project_dir.join("ledger");
156    let transcripts_dir = project_dir.join("transcripts");
157
158    fs::create_dir_all(&state_dir)?;
159    fs::create_dir_all(&ledger_dir)?;
160    fs::create_dir_all(&transcripts_dir)?;
161
162    // Session-level lock to guard concurrent ingestion
163    let lock_path = state_dir.join(format!("ingest_pi.{session_id}.lock"));
164    let _lock = edda_store::lock_file(&lock_path)?;
165
166    // Cursor path
167    let cursor_path = state_dir.join(format!("pi_transcript_cursor.{session_id}.json"));
168    let mut cursor = if cursor_path.exists() {
169        let content = fs::read_to_string(&cursor_path)?;
170        serde_json::from_str(&content).unwrap_or(TranscriptCursor {
171            offset: 0,
172            file_size: 0,
173            mtime_unix: 0,
174            updated_at_unix: 0,
175        })
176    } else {
177        TranscriptCursor {
178            offset: 0,
179            file_size: 0,
180            mtime_unix: 0,
181            updated_at_unix: 0,
182        }
183    };
184
185    let meta = fs::metadata(transcript_path)?;
186    let file_size = meta.len();
187    cursor.detect_truncation(file_size);
188
189    if cursor.offset >= file_size {
190        return Ok(PiIngestStats {
191            from_offset: cursor.offset,
192            to_offset: cursor.offset,
193            ..Default::default()
194        });
195    }
196
197    let max_bytes: u64 = std::env::var("EDDA_TRANSCRIPT_MAX_BYTES")
198        .ok()
199        .and_then(|v| v.parse().ok())
200        .unwrap_or(DEFAULT_MAX_BYTES);
201
202    let mut file = fs::File::open(transcript_path)?;
203    file.seek(SeekFrom::Start(cursor.offset))?;
204
205    let bytes_to_read = (file_size - cursor.offset).min(max_bytes);
206    let mut buf = vec![0u8; bytes_to_read as usize];
207    let actually_read = file.read(&mut buf)?;
208    buf.truncate(actually_read);
209
210    // Partial line protection: only consume up to the last newline
211    let consumable_len = match buf.iter().rposition(|&b| b == b'\n') {
212        Some(pos) => pos + 1,
213        None => 0,
214    };
215
216    if consumable_len == 0 {
217        return Ok(PiIngestStats {
218            from_offset: cursor.offset,
219            to_offset: cursor.offset,
220            ..Default::default()
221        });
222    }
223
224    let from_offset = cursor.offset;
225    let to_offset = from_offset + consumable_len as u64;
226    let data = &buf[..consumable_len];
227
228    // Transcripts raw store path
229    let raw_store_path = transcripts_dir.join(format!("{session_id}.jsonl"));
230    let mut raw_store_file = fs::OpenOptions::new()
231        .create(true)
232        .append(true)
233        .open(&raw_store_path)?;
234
235    // Session ledger path
236    let ledger_path = ledger_dir.join(format!("{session_id}.jsonl"));
237    let mut ledger_file = fs::OpenOptions::new()
238        .create(true)
239        .append(true)
240        .open(&ledger_path)?;
241
242    let project_id = project_dir
243        .file_name()
244        .and_then(|f| f.to_str())
245        .unwrap_or_default()
246        .to_string();
247    let cwd_str = cwd.to_string_lossy().to_string();
248
249    let mut stats = PiIngestStats {
250        bytes_read: consumable_len as u64,
251        from_offset,
252        to_offset,
253        ..Default::default()
254    };
255
256    // Load existing usage state if present
257    let usage_path = state_dir.join("usage.json");
258    let current_usage = if usage_path.exists() {
259        fs::read_to_string(&usage_path)
260            .ok()
261            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
262            .unwrap_or_default()
263    } else {
264        serde_json::Value::Null
265    };
266
267    let mut model_seen = String::new();
268    let mut total_input = 0u64;
269    let mut total_output = 0u64;
270    let mut total_cache_read = 0u64;
271    let mut total_cache_write = 0u64;
272    let mut total_cost = 0.0f64;
273    let mut usage_observed = false;
274    let mut cost_observed = false;
275
276    let default_ts = now_rfc3339();
277
278    for raw_line in data.split(|&b| b == b'\n') {
279        if raw_line.is_empty() {
280            continue;
281        }
282        stats.records_read += 1;
283
284        // Write raw line verbatim to transcripts/
285        raw_store_file.write_all(raw_line)?;
286        raw_store_file.write_all(b"\n")?;
287        stats.records_kept += 1;
288
289        let parsed: serde_json::Value = match serde_json::from_slice(raw_line) {
290            Ok(v) => v,
291            Err(_) => continue,
292        };
293
294        let ts = parsed
295            .get("timestamp")
296            .and_then(|t| t.as_str())
297            .unwrap_or(&default_ts)
298            .to_string();
299
300        let line_type = parsed.get("type").and_then(|t| t.as_str()).unwrap_or("");
301
302        if line_type == "message" {
303            if let Some(msg) = parsed.get("message") {
304                let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
305                match role {
306                    "assistant" => {
307                        // Extract model & usage if present
308                        if let Some(m) = msg
309                            .get("model")
310                            .or_else(|| parsed.get("model"))
311                            .and_then(|v| v.as_str())
312                        {
313                            if !m.is_empty() {
314                                model_seen = m.to_string();
315                            }
316                        }
317
318                        if let Some(u) = msg.get("usage") {
319                            usage_observed = true;
320                            if let Some(inp) = u.get("input").and_then(|v| v.as_u64()) {
321                                total_input += inp;
322                            }
323                            if let Some(out) = u.get("output").and_then(|v| v.as_u64()) {
324                                total_output += out;
325                            }
326                            if let Some(cr) = u.get("cacheRead").and_then(|v| v.as_u64()) {
327                                total_cache_read += cr;
328                            }
329                            if let Some(cw) = u.get("cacheWrite").and_then(|v| v.as_u64()) {
330                                total_cache_write += cw;
331                            }
332                            if let Some(cost) = u
333                                .get("cost")
334                                .and_then(|c| c.get("total"))
335                                .and_then(|v| v.as_f64())
336                            {
337                                total_cost += cost;
338                                cost_observed = true;
339                            }
340                        }
341
342                        // Extract tool calls from content array
343                        if let Some(content_arr) = msg.get("content").and_then(|c| c.as_array()) {
344                            for item in content_arr {
345                                if item.get("type").and_then(|t| t.as_str()) == Some("toolCall") {
346                                    stats.tool_calls += 1;
347                                    let tool_name =
348                                        item.get("name").and_then(|n| n.as_str()).unwrap_or("");
349                                    let tool_id =
350                                        item.get("id").and_then(|i| i.as_str()).unwrap_or("");
351                                    let tool_input = item
352                                        .get("arguments")
353                                        .cloned()
354                                        .unwrap_or(serde_json::Value::Null);
355
356                                    let record = serde_json::json!({
357                                        "ts": ts,
358                                        "project_id": project_id,
359                                        "session_id": session_id,
360                                        "hook_event_name": "PostToolUse",
361                                        "tool_name": normalize_tool_name(tool_name),
362                                        "tool_use_id": tool_id,
363                                        "cwd": cwd_str,
364                                        "model": model_seen,
365                                        "bridge": "pi",
366                                        "tool_input": tool_input,
367                                    });
368                                    writeln!(ledger_file, "{}", serde_json::to_string(&record)?)?;
369                                }
370                            }
371                        }
372                    }
373                    "toolResult" => {
374                        let is_error = msg
375                            .get("isError")
376                            .and_then(|e| e.as_bool())
377                            .unwrap_or(false);
378                        if is_error {
379                            stats.tool_failures += 1;
380                            let tool_name =
381                                msg.get("toolName").and_then(|n| n.as_str()).unwrap_or("");
382                            let tool_id =
383                                msg.get("toolCallId").and_then(|i| i.as_str()).unwrap_or("");
384                            let tool_content = msg
385                                .get("content")
386                                .cloned()
387                                .unwrap_or(serde_json::Value::Null);
388
389                            let record = serde_json::json!({
390                                "ts": ts,
391                                "project_id": project_id,
392                                "session_id": session_id,
393                                "hook_event_name": "PostToolUseFailure",
394                                "tool_name": normalize_tool_name(tool_name),
395                                "tool_use_id": tool_id,
396                                "cwd": cwd_str,
397                                "bridge": "pi",
398                                "tool_response": tool_content,
399                            });
400                            writeln!(ledger_file, "{}", serde_json::to_string(&record)?)?;
401                        }
402                    }
403                    "user" => {
404                        stats.user_prompts += 1;
405                        let record = serde_json::json!({
406                            "ts": ts,
407                            "project_id": project_id,
408                            "session_id": session_id,
409                            "hook_event_name": "UserPromptSubmit",
410                            "cwd": cwd_str,
411                            "bridge": "pi",
412                        });
413                        writeln!(ledger_file, "{}", serde_json::to_string(&record)?)?;
414                    }
415                    _ => {}
416                }
417            }
418        }
419    }
420
421    stats.model = model_seen.clone();
422    stats.input_tokens = total_input;
423    stats.output_tokens = total_output;
424    stats.cache_read_tokens = total_cache_read;
425    stats.cache_creation_tokens = total_cache_write;
426    stats.cost_usd = if cost_observed {
427        Some(total_cost)
428    } else {
429        None
430    };
431
432    // Update usage state file for the digest reader
433    if usage_observed || !model_seen.is_empty() {
434        // GH-577 round 2 (P1-3): usage.json is project-scoped. If it records
435        // a DIFFERENT session_id, do NOT accumulate into it — reset to zero
436        // so one session's digest never reports another session's tokens.
437        // Only accumulate if resuming the same session.
438        let is_same_session =
439            current_usage.get("session_id").and_then(|s| s.as_str()) == Some(session_id);
440
441        let prev_u = if is_same_session {
442            current_usage.get("usage").cloned().unwrap_or_default()
443        } else {
444            serde_json::Value::Null
445        };
446
447        let prev_in = prev_u
448            .get("input_tokens")
449            .and_then(|v| v.as_u64())
450            .unwrap_or(0);
451        let prev_out = prev_u
452            .get("output_tokens")
453            .and_then(|v| v.as_u64())
454            .unwrap_or(0);
455        let prev_cr = prev_u
456            .get("cache_read_tokens")
457            .and_then(|v| v.as_u64())
458            .unwrap_or(0);
459        let prev_cw = prev_u
460            .get("cache_creation_tokens")
461            .and_then(|v| v.as_u64())
462            .unwrap_or(0);
463        let prev_cost = prev_u.get("cost_usd").and_then(|v| v.as_f64());
464        let final_cost = match (prev_cost, cost_observed) {
465            (Some(prev), true) => Some(prev + total_cost),
466            (Some(prev), false) => Some(prev),
467            (None, true) => Some(total_cost),
468            (None, false) => None,
469        };
470
471        let usage_obj = serde_json::json!({
472            "session_id": session_id,
473            "updated_at": now_rfc3339(),
474            "usage": {
475                "model": if model_seen.is_empty() {
476                    prev_u.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string()
477                } else {
478                    model_seen
479                },
480                "input_tokens": prev_in + total_input,
481                "output_tokens": prev_out + total_output,
482                "cache_read_tokens": prev_cr + total_cache_read,
483                "cache_creation_tokens": prev_cw + total_cache_write,
484                "cost_usd": final_cost,
485                "usage_observed": true,
486            }
487        });
488        fs::write(&usage_path, serde_json::to_string_pretty(&usage_obj)?)?;
489    }
490
491    // Save updated cursor
492    cursor.offset = to_offset;
493    cursor.file_size = file_size;
494    cursor.mtime_unix = meta
495        .modified()
496        .ok()
497        .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
498        .map(|d| d.as_secs() as i64)
499        .unwrap_or(0);
500    cursor.updated_at_unix = std::time::SystemTime::now()
501        .duration_since(std::time::UNIX_EPOCH)
502        .map(|d| d.as_secs() as i64)
503        .unwrap_or(0);
504    let data = serde_json::to_string_pretty(&cursor)?;
505    edda_store::write_atomic(&cursor_path, data.as_bytes())?;
506
507    Ok(stats)
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn test_pi_session_dir_for_cwd() {
516        let p = Path::new("C:\\ai_agent\\edda");
517        let dir = pi_session_dir_for_cwd(p).expect("should compute dir");
518        let dir_str = dir.to_string_lossy();
519        assert!(
520            dir_str.contains("--C--ai_agent-edda--") || dir_str.contains(".pi"),
521            "got {dir_str}"
522        );
523    }
524
525    #[test]
526    fn test_find_pi_session_file_by_name_and_header() {
527        let tmp = tempfile::tempdir().unwrap();
528        let session_id = "test-sess-uuid-1234";
529
530        let sess_file = tmp
531            .path()
532            .join(format!("2026-09-02T12-00-00-000Z_{session_id}.jsonl"));
533        fs::write(
534            &sess_file,
535            format!(r#"{{"type":"session","id":"{session_id}"}}"#),
536        )
537        .unwrap();
538
539        let cwd = Path::new("C:\\test\\dir");
540        let found = find_pi_session_file(cwd, session_id, Some(tmp.path()));
541        assert_eq!(found, Some(sess_file));
542    }
543}