1use crate::cursor::TranscriptCursor;
14use serde::{Deserialize, Serialize};
15use std::fs;
16use std::io::{Read, Seek, SeekFrom, Write};
17use std::path::{Path, PathBuf};
18
19#[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
38pub 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
60pub 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 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 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; #[allow(clippy::too_many_lines)] pub 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 let lock_path = state_dir.join(format!("ingest_pi.{session_id}.lock"));
164 let _lock = edda_store::lock_file(&lock_path)?;
165
166 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 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 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 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 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 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 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 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 if usage_observed || !model_seen.is_empty() {
434 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 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}