1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
37use std::io::BufRead;
38use std::path::{Path, PathBuf};
39
40use serde::Serialize;
41
42use crate::mailbox::{Envelope, MailAddress, MailKind, Mailbox, ReplyVia, StoredEnvelope};
43use crate::HarnessHomes;
44
45pub const TYPED_ID_PREFIX: &str = "u-";
47
48pub const ANSWER_ID_PREFIX: &str = "a-";
50
51pub fn answer_id(address: &MailAddress, native_id: &str) -> String {
54 let hash = blake3::hash(format!("{address}\n{native_id}").as_bytes()).to_hex();
55 format!("{ANSWER_ID_PREFIX}{}", &hash[..24])
56}
57
58pub fn user_address(machine: &str) -> std::io::Result<MailAddress> {
61 MailAddress::new(machine.to_string(), "operator", "user")
62 .map_err(|error| std::io::Error::other(error.0))
63}
64
65pub fn typed_line_id(address: &MailAddress, sent_at_ms: u64, text: &str) -> String {
68 let hash = blake3::hash(format!("{address}\n{sent_at_ms}\n{text}").as_bytes()).to_hex();
69 format!("{TYPED_ID_PREFIX}{}", &hash[..24])
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct TypedLine {
75 pub sent_at_ms: u64,
77 pub delivered_at_ms: Option<u64>,
79 pub text: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Answer {
86 pub native_id: String,
88 pub at_ms: u64,
90 pub text: String,
92}
93
94#[derive(Debug, Clone, Default)]
96pub struct TranscriptMail {
97 pub typed: Vec<TypedLine>,
99 pub answers: Vec<Answer>,
101 pub delivered: HashMap<String, u64>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(tag = "state", rename_all = "snake_case")]
108pub enum Delivery {
109 Delivered {
111 at_ms: u64,
113 },
114 Sent,
116 Unknown,
119}
120
121impl Delivery {
122 pub fn describe(&self) -> String {
124 match self {
125 Self::Delivered { at_ms } => {
126 supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
127 }
128 Self::Sent => "not yet".to_string(),
129 Self::Unknown => "unknown".to_string(),
130 }
131 }
132}
133
134pub fn transcript(homes: &HarnessHomes, address: &MailAddress) -> Option<PathBuf> {
137 if address.harness != "claude-code" {
138 return None;
139 }
140 let file = format!("{}.jsonl", address.session_id);
141 std::fs::read_dir(&homes.claude_code)
142 .ok()?
143 .flatten()
144 .map(|project| project.path().join(&file))
145 .find(|path| path.is_file())
146}
147
148fn at_ms(value: &serde_json::Value) -> u64 {
149 value
150 .as_str()
151 .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
152 .and_then(|at| u64::try_from(at).ok())
153 .unwrap_or_default()
154}
155
156fn prompt_text(content: &serde_json::Value) -> Option<String> {
157 let text = match content {
158 serde_json::Value::String(text) => text.clone(),
159 serde_json::Value::Array(parts) => parts
160 .iter()
161 .filter(|part| part["type"] == "text")
162 .filter_map(|part| part["text"].as_str())
163 .collect::<Vec<_>>()
164 .join("\n"),
165 _ => return None,
166 };
167 (!text.trim().is_empty()).then_some(text)
168}
169
170fn rendered_mail_ids(line: &str) -> Vec<&str> {
174 let mut ids = Vec::new();
175 for mark in ["cross-session-message id=\\\"", "(message "] {
176 for (at, _) in line.match_indices(mark) {
177 let rest = &line[at + mark.len()..];
178 let end = rest
179 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
180 .unwrap_or(rest.len());
181 let id = &rest[..end];
182 if id.starts_with("m-") {
183 ids.push(id);
184 }
185 }
186 }
187 ids
188}
189
190pub fn read_claude(path: &Path) -> std::io::Result<TranscriptMail> {
192 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
193 let mut mail = TranscriptMail::default();
194 let mut queued: VecDeque<(String, u64)> = VecDeque::new();
199 let mut absorbed: VecDeque<(String, u64)> = VecDeque::new();
201 let take = |waiting: &mut VecDeque<(String, u64)>, text: &str| {
202 let at = waiting.iter().position(|(words, _)| words == text)?;
203 waiting.remove(at).map(|(_, when)| when)
204 };
205 for line in reader.lines() {
206 let line = line?;
207 let prompt = line.contains("\"promptSource\"");
208 let queue = line.contains("\"queue-operation\"");
209 let attachment = line.contains("\"queued_command\"");
210 let answer = line.contains("\"stop_reason\":\"end_turn\"")
211 || line.contains("\"stop_reason\":\"stop_sequence\"");
212 let rendered = line.contains("cross-session-message id=") || line.contains("(message m-");
213 if !(prompt || queue || attachment || rendered || answer) {
214 continue;
215 }
216 let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
217 continue;
218 };
219 if record["isSidechain"] == true {
220 continue;
221 }
222 let kind = record["type"].as_str().unwrap_or_default();
223 let at = at_ms(&record["timestamp"]);
224 if rendered && matches!(kind, "user" | "attachment") {
228 for id in rendered_mail_ids(&line) {
229 mail.delivered.entry(id.to_string()).or_insert(at);
230 }
231 }
232 match kind {
233 "assistant"
235 if answer
236 && matches!(
237 record["message"]["stop_reason"].as_str(),
238 Some("end_turn" | "stop_sequence")
239 ) =>
240 {
241 let (Some(native_id), Some(text)) = (
242 record["uuid"].as_str(),
243 prompt_text(&record["message"]["content"]),
244 ) else {
245 continue;
246 };
247 mail.answers.push(Answer {
248 native_id: native_id.to_string(),
249 at_ms: at,
250 text,
251 });
252 }
253 "queue-operation" => {
254 let Some(text) = record["content"].as_str() else {
255 continue;
256 };
257 match record["operation"].as_str() {
258 Some("enqueue") => queued.push_back((text.to_string(), at)),
259 Some("remove") => {
260 if take(&mut queued, text).is_some() {
261 absorbed.push_back((text.to_string(), at));
262 }
263 }
264 _ => {}
265 }
266 }
267 "attachment" => {
270 let attached = &record["attachment"];
271 let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
272 let Some(text) = attached["prompt"].as_str() else {
273 continue;
274 };
275 if attached["type"] != "queued_command" || !human || text.trim().is_empty() {
276 continue;
277 }
278 let delivered = take(&mut absorbed, text).unwrap_or(at);
279 mail.typed.push(TypedLine {
280 sent_at_ms: at_ms(&attached["timestamp"]).min(delivered),
281 delivered_at_ms: Some(delivered),
282 text: text.to_string(),
283 });
284 }
285 "user" => {
286 let source = record["promptSource"].as_str();
287 if !matches!(source, Some("typed" | "queued")) || record["isMeta"] == true {
288 continue;
289 }
290 let Some(text) = prompt_text(&record["message"]["content"]) else {
291 continue;
292 };
293 let sent = match source {
294 Some("queued") => take(&mut queued, &text).unwrap_or(at),
295 _ => at,
296 };
297 mail.typed.push(TypedLine {
298 sent_at_ms: sent,
299 delivered_at_ms: Some(at),
300 text,
301 });
302 }
303 _ => {}
304 }
305 }
306 Ok(mail)
309}
310
311pub fn read(homes: &HarnessHomes, address: &MailAddress) -> Option<TranscriptMail> {
313 read_claude(&transcript(homes, address)?).ok()
314}
315
316pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
319 let address = mailbox.address();
320 let filed = mailbox.list()?;
321 let ids: HashSet<&str> = filed
322 .iter()
323 .map(|stored| stored.envelope.id.as_str())
324 .collect();
325 let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
328 for stored in &filed {
329 if stored.envelope.kind == MailKind::User
330 && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
331 {
332 *typed_by_supercode
333 .entry(stored.envelope.body.trim())
334 .or_default() += 1;
335 }
336 }
337 let user = user_address(&address.machine)?;
338 let mut count = 0;
339 for line in &mail.typed {
340 let id = typed_line_id(address, line.sent_at_ms, &line.text);
341 if ids.contains(id.as_str()) {
342 continue;
343 }
344 if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
345 if *left > 0 {
346 *left -= 1;
347 continue;
348 }
349 }
350 mailbox.file_read(&Envelope {
351 id,
352 created_at_ms: line.sent_at_ms,
353 from: user.clone(),
354 from_name: format!("user@{}", address.machine),
355 kind: MailKind::User,
356 reply_via: ReplyVia::None,
357 in_reply_to: None,
358 in_reply_to_inferred: false,
359 native_from: None,
360 body: line.text.clone(),
361 })?;
362 count += 1;
363 }
364 Ok(count)
365}
366
367pub fn file_answers(
371 root: &Path,
372 address: &MailAddress,
373 name: &str,
374 mail: &TranscriptMail,
375) -> std::io::Result<usize> {
376 let mailbox = Mailbox::open(root, &user_address(&address.machine)?)?;
377 let filed: HashSet<String> = mailbox
378 .list()?
379 .into_iter()
380 .map(|stored| stored.envelope.id)
381 .collect();
382 let mut count = 0;
383 for answer in &mail.answers {
384 let id = answer_id(address, &answer.native_id);
385 if filed.contains(&id) {
386 continue;
387 }
388 mailbox.file_read(&Envelope {
389 id,
390 created_at_ms: answer.at_ms,
391 from: address.clone(),
392 from_name: name.to_string(),
393 kind: MailKind::Peer,
394 reply_via: ReplyVia::None,
395 in_reply_to: None,
396 in_reply_to_inferred: false,
397 native_from: None,
398 body: answer.text.clone(),
399 })?;
400 count += 1;
401 }
402 Ok(count)
403}
404
405pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
409 let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
410 .into_iter()
411 .filter(|mailbox| mailbox.address() != address)
412 .flat_map(|mailbox| {
413 let to = mailbox.address().clone();
414 mailbox
415 .list()
416 .unwrap_or_default()
417 .into_iter()
418 .filter(|stored| &stored.envelope.from == address)
419 .map(move |stored| (to.clone(), stored))
420 })
421 .collect();
422 sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
423 sent
424}
425
426pub fn deliveries(
429 address: &MailAddress,
430 stored: &[StoredEnvelope],
431 mail: Option<&TranscriptMail>,
432) -> BTreeMap<String, Delivery> {
433 let Some(mail) = mail else {
434 return stored
435 .iter()
436 .map(|stored| (stored.envelope.id.clone(), Delivery::Unknown))
437 .collect();
438 };
439 let typed: HashMap<String, &TypedLine> = mail
440 .typed
441 .iter()
442 .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
443 .collect();
444 let mut used: HashSet<usize> = HashSet::new();
447 let mut result = BTreeMap::new();
448 for stored in stored {
449 let envelope = &stored.envelope;
450 let at = if let Some(line) = typed.get(&envelope.id) {
451 line.delivered_at_ms
452 } else if let Some(at) = mail.delivered.get(&envelope.id) {
453 Some(*at)
454 } else if envelope.kind == MailKind::User {
455 mail.typed
456 .iter()
457 .enumerate()
458 .find(|(index, line)| {
459 !used.contains(index)
460 && line.sent_at_ms + 1_000 >= envelope.created_at_ms
461 && line.text.trim() == envelope.body.trim()
462 })
463 .and_then(|(index, line)| {
464 used.insert(index);
465 line.delivered_at_ms
466 })
467 } else {
468 None
469 };
470 result.insert(
471 envelope.id.clone(),
472 match at {
473 Some(at_ms) => Delivery::Delivered { at_ms },
474 None => Delivery::Sent,
475 },
476 );
477 }
478 result
479}
480
481pub fn find_message(root: &Path, id: &str) -> std::io::Result<Option<(Mailbox, StoredEnvelope)>> {
484 for mailbox in crate::mailbox::all_mailboxes(root) {
485 if let Some(stored) = mailbox.find(id)? {
486 return Ok(Some((mailbox, stored)));
487 }
488 }
489 Ok(None)
490}