1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
40use std::io::BufRead;
41use std::path::{Path, PathBuf};
42
43use serde::Serialize;
44
45use crate::mailbox::{Envelope, MailAddress, MailKind, Mailbox, ReplyVia, StoredEnvelope};
46use crate::HarnessHomes;
47
48pub const TYPED_ID_PREFIX: &str = "u-";
50
51pub const CHANNEL_ID_PREFIX: &str = "c-";
54
55#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ChannelLine {
59 pub header: String,
61 pub from: String,
63 pub sent_at_ms: u64,
65 pub delivered_at_ms: Option<u64>,
67 pub text: String,
69}
70
71pub fn channel_line_id(address: &MailAddress, header: &str) -> String {
73 let hash = blake3::hash(format!("{address}\n{header}").as_bytes()).to_hex();
74 format!("{CHANNEL_ID_PREFIX}{}", &hash[..24])
75}
76
77fn channel_lines(text: &str, fallback_ms: u64, delivered_at_ms: Option<u64>) -> Vec<ChannelLine> {
80 let starts: Vec<usize> = text
81 .match_indices("[channel: ")
82 .map(|(at, _)| at)
83 .filter(|at| *at == 0 || text[..*at].ends_with('\n'))
84 .collect();
85 starts
86 .iter()
87 .enumerate()
88 .filter_map(|(n, start)| {
89 let end = starts.get(n + 1).copied().unwrap_or(text.len());
90 let line = text[*start..end].trim_end();
91 let header = &line[..line.find(']')? + 1];
92 let field = |name: &str| {
93 header
94 .split(" · ")
95 .find_map(|part| part.strip_prefix(name))
96 .map(|value| value.trim_end_matches(']').trim().to_string())
97 };
98 Some(ChannelLine {
99 header: header.to_string(),
100 from: field("from: ").unwrap_or_else(|| "a channel".to_string()),
101 sent_at_ms: field("at: ")
102 .and_then(|at| supercode_interchange::sidecar::rfc3339_to_ms(&at))
103 .and_then(|at| u64::try_from(at).ok())
104 .unwrap_or(fallback_ms),
105 delivered_at_ms,
106 text: line.to_string(),
107 })
108 })
109 .collect()
110}
111
112pub const ANSWER_ID_PREFIX: &str = "a-";
114
115pub fn answer_id(address: &MailAddress, native_id: &str) -> String {
118 let hash = blake3::hash(format!("{address}\n{native_id}").as_bytes()).to_hex();
119 format!("{ANSWER_ID_PREFIX}{}", &hash[..24])
120}
121
122pub fn user_address(machine: &str) -> std::io::Result<MailAddress> {
125 MailAddress::new(machine.to_string(), "operator", "user")
126 .map_err(|error| std::io::Error::other(error.0))
127}
128
129pub fn typed_line_id(address: &MailAddress, sent_at_ms: u64, text: &str) -> String {
132 let hash = blake3::hash(format!("{address}\n{sent_at_ms}\n{text}").as_bytes()).to_hex();
133 format!("{TYPED_ID_PREFIX}{}", &hash[..24])
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct TypedLine {
139 pub sent_at_ms: u64,
141 pub delivered_at_ms: Option<u64>,
143 pub withdrawn: bool,
145 pub text: String,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Answer {
152 pub native_id: String,
154 pub at_ms: u64,
156 pub text: String,
158}
159
160#[derive(Debug, Clone, Default)]
162pub struct TranscriptMail {
163 pub typed: Vec<TypedLine>,
165 pub answers: Vec<Answer>,
167 pub channel: Vec<ChannelLine>,
169 pub delivered: HashMap<String, u64>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
175#[serde(tag = "state", rename_all = "snake_case")]
176pub enum Delivery {
177 Delivered {
179 at_ms: u64,
181 },
182 Sent,
184 Withdrawn,
186 Unknown,
189 NotRead,
192}
193
194impl Delivery {
195 pub fn describe(&self) -> String {
197 match self {
198 Self::Delivered { at_ms } => {
199 supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
200 }
201 Self::Sent => "not yet".to_string(),
202 Self::Withdrawn => "never (taken back)".to_string(),
203 Self::Unknown => "unknown".to_string(),
204 Self::NotRead => "not read here (supercode message show reads it)".to_string(),
205 }
206 }
207}
208
209pub fn transcript(homes: &HarnessHomes, address: &MailAddress) -> Option<PathBuf> {
212 if address.harness != "claude-code" {
213 return None;
214 }
215 let file = format!("{}.jsonl", address.session_id);
216 std::fs::read_dir(&homes.claude_code)
217 .ok()?
218 .flatten()
219 .map(|project| project.path().join(&file))
220 .find(|path| path.is_file())
221}
222
223fn at_ms(value: &serde_json::Value) -> u64 {
224 value
225 .as_str()
226 .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
227 .and_then(|at| u64::try_from(at).ok())
228 .unwrap_or_default()
229}
230
231fn prompt_text(content: &serde_json::Value) -> Option<String> {
232 let text = match content {
233 serde_json::Value::String(text) => text.clone(),
234 serde_json::Value::Array(parts) => parts
235 .iter()
236 .filter(|part| part["type"] == "text")
237 .filter_map(|part| part["text"].as_str())
238 .collect::<Vec<_>>()
239 .join("\n"),
240 _ => return None,
241 };
242 (!text.trim().is_empty()).then_some(text)
243}
244
245fn rendered_mail_ids(line: &str) -> Vec<&str> {
249 let mut ids = Vec::new();
250 for mark in ["cross-session-message id=\\\"", "(message "] {
251 for (at, _) in line.match_indices(mark) {
252 let rest = &line[at + mark.len()..];
253 let end = rest
254 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
255 .unwrap_or(rest.len());
256 let id = &rest[..end];
257 if id.starts_with("m-") {
258 ids.push(id);
259 }
260 }
261 }
262 ids
263}
264
265fn machine_made(text: &str) -> bool {
268 text.contains("<cross-session-message")
269 || text.starts_with("[Cross-session")
270 || text.starts_with("[channel: ")
271 || text.starts_with("<task-notification>")
272}
273
274pub fn read_claude(path: &Path) -> std::io::Result<TranscriptMail> {
276 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
277 let mut mail = TranscriptMail::default();
278 let mut queued: VecDeque<(String, Option<usize>)> = VecDeque::new();
284 let mut dequeued = 0_usize;
287 let mut absorbed: VecDeque<(String, Option<usize>)> = VecDeque::new();
290 let take = |waiting: &mut VecDeque<(String, Option<usize>)>, text: &str| {
291 let at = waiting.iter().position(|(words, _)| words == text)?;
292 waiting.remove(at).map(|(_, index)| index)
293 };
294 let mut not_typed: HashSet<usize> = HashSet::new();
296 let mut taken_in: HashMap<String, VecDeque<u64>> = HashMap::new();
299 let mut early: Vec<(String, Vec<String>, Option<usize>, u64)> = Vec::new();
303 for line in reader.lines() {
304 let line = line?;
305 let prompt = line.contains("\"promptSource\"") || line.contains("\"type\":\"user\"");
306 let queue = line.contains("\"queue-operation\"");
307 let attachment = line.contains("\"queued_command\"");
308 let answer = line.contains("\"stop_reason\":\"end_turn\"")
309 || line.contains("\"stop_reason\":\"stop_sequence\"");
310 let rendered = line.contains("cross-session-message id=") || line.contains("(message m-");
311 if !(prompt || queue || attachment || rendered || answer) {
312 continue;
313 }
314 let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
315 continue;
316 };
317 if record["isSidechain"] == true {
318 continue;
319 }
320 let kind = record["type"].as_str().unwrap_or_default();
321 let mut at = at_ms(&record["timestamp"]);
322 if kind == "queue-operation" && record["operation"] == "remove" {
323 if let Some(text) = record["content"].as_str() {
324 if let Some(found) = early.iter().position(|(words, ..)| words == text) {
327 let (_, ids, index, _) = early.remove(found);
328 for id in ids {
329 mail.delivered.entry(id).or_insert(at);
330 }
331 if let Some(index) = index {
332 mail.typed[index].delivered_at_ms = Some(at);
333 }
334 continue;
335 }
336 taken_in.entry(text.to_string()).or_default().push_back(at);
337 }
338 }
339 let mut waits_for_remove = false;
340 if kind == "attachment" && record["attachment"]["type"] == "queued_command" {
341 match record["attachment"]["prompt"]
342 .as_str()
343 .and_then(|text| taken_in.get_mut(text))
344 .and_then(VecDeque::pop_front)
345 {
346 Some(taken) => at = taken,
347 None => waits_for_remove = true,
348 }
349 }
350 if waits_for_remove {
351 let attached = &record["attachment"];
352 let text = attached["prompt"].as_str().unwrap_or_default().to_string();
353 let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
354 let ids = rendered_mail_ids(&line)
355 .into_iter()
356 .map(str::to_string)
357 .collect();
358 let index = match take(&mut queued, &text) {
360 Some(Some(index)) if !human => {
361 not_typed.insert(index);
362 None
363 }
364 Some(index) => index,
365 None if human && !text.trim().is_empty() => {
366 mail.typed.push(TypedLine {
367 sent_at_ms: at,
368 delivered_at_ms: None,
369 withdrawn: false,
370 text: text.clone(),
371 });
372 Some(mail.typed.len() - 1)
373 }
374 None => None,
375 };
376 early.push((text, ids, index, at));
377 continue;
378 }
379 if rendered && matches!(kind, "user" | "attachment") {
383 for id in rendered_mail_ids(&line) {
384 mail.delivered.entry(id.to_string()).or_insert(at);
385 }
386 }
387 match kind {
388 "assistant"
390 if answer
391 && matches!(
392 record["message"]["stop_reason"].as_str(),
393 Some("end_turn" | "stop_sequence")
394 ) =>
395 {
396 let (Some(native_id), Some(text)) = (
397 record["uuid"].as_str(),
398 prompt_text(&record["message"]["content"]),
399 ) else {
400 continue;
401 };
402 mail.answers.push(Answer {
403 native_id: native_id.to_string(),
404 at_ms: at,
405 text,
406 });
407 }
408 "queue-operation" if record["operation"] == "dequeue" => dequeued += 1,
410 "queue-operation" => {
411 let Some(text) = record["content"].as_str() else {
412 continue;
413 };
414 match record["operation"].as_str() {
415 Some("enqueue") => {
416 let index = (!machine_made(text) && !text.trim().is_empty()).then(|| {
417 mail.typed.push(TypedLine {
418 sent_at_ms: at,
419 delivered_at_ms: None,
420 withdrawn: false,
421 text: text.to_string(),
422 });
423 mail.typed.len() - 1
424 });
425 queued.push_back((text.to_string(), index));
426 }
427 Some("remove") => {
428 let Some(index) = take(&mut queued, text) else {
429 continue;
430 };
431 match record["reason"].as_str() {
432 Some("absorbed_mid_turn" | "delivered_to_agent") => {
434 if let Some(index) = index {
435 mail.typed[index].delivered_at_ms = Some(at);
436 }
437 absorbed.push_back((text.to_string(), index));
438 }
439 _ => {
441 if let Some(index) = index {
442 mail.typed[index].withdrawn = true;
443 }
444 }
445 }
446 }
447 _ => {}
448 }
449 }
450 "attachment" => {
453 let attached = &record["attachment"];
454 let Some(text) = attached["prompt"].as_str() else {
455 continue;
456 };
457 if attached["type"] != "queued_command" || text.trim().is_empty() {
458 continue;
459 }
460 let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
461 match take(&mut absorbed, text) {
462 Some(Some(index)) if !human => {
463 not_typed.insert(index);
464 }
465 Some(_) => {}
466 None if human => mail.typed.push(TypedLine {
467 sent_at_ms: at_ms(&attached["timestamp"]).min(at),
468 delivered_at_ms: Some(at),
469 withdrawn: false,
470 text: text.to_string(),
471 }),
472 None => {}
473 }
474 }
475 "user" => {
476 let Some(text) = prompt_text(&record["message"]["content"]) else {
477 continue;
478 };
479 let source = record["promptSource"].as_str();
480 let human = matches!(source, Some("typed" | "queued")) && record["isMeta"] != true;
481 if !human {
482 mail.channel.extend(channel_lines(&text, at, Some(at)));
483 }
484 let mut landing: Vec<Option<usize>> = Vec::new();
491 let let_go = std::mem::take(&mut dequeued);
492 if let_go == 0 {
493 landing.extend(take(&mut queued, &text));
494 }
495 let wanted = let_go;
496 while landing.len() < wanted {
497 let Some(at) = queued
498 .iter()
499 .position(|(words, _)| text.contains(words.as_str()))
500 else {
501 break;
502 };
503 landing.extend(queued.remove(at).map(|(_, index)| index));
504 }
505 if landing.is_empty() && wanted > 0 {
506 for _ in 0..wanted {
507 landing.extend(queued.pop_front().map(|(_, index)| index));
508 }
509 }
510 if !landing.is_empty() {
511 for index in landing.into_iter().flatten() {
512 if human {
513 mail.typed[index].delivered_at_ms = Some(at);
514 } else {
515 not_typed.insert(index);
516 }
517 }
518 continue;
519 }
520 if human {
521 mail.typed.push(TypedLine {
522 sent_at_ms: at,
523 delivered_at_ms: Some(at),
524 withdrawn: false,
525 text,
526 });
527 }
528 }
529 _ => {}
530 }
531 }
532 for (_, ids, index, at) in early {
535 for id in ids {
536 mail.delivered.entry(id).or_insert(at);
537 }
538 if let Some(index) = index {
539 mail.typed[index].delivered_at_ms.get_or_insert(at);
540 }
541 }
542 for (text, _) in &queued {
544 if text.starts_with("[channel: ") {
545 mail.channel.extend(channel_lines(text, 0, None));
546 }
547 }
548 let mut index = 0;
549 mail.typed.retain(|_| {
550 index += 1;
551 !not_typed.contains(&(index - 1))
552 });
553 Ok(mail)
554}
555
556pub fn read(homes: &HarnessHomes, address: &MailAddress) -> Option<TranscriptMail> {
558 read_claude(&transcript(homes, address)?).ok()
559}
560
561pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
564 let address = mailbox.address();
565 let filed = mailbox.list()?;
566 let ids: HashSet<&str> = filed
567 .iter()
568 .map(|stored| stored.envelope.id.as_str())
569 .collect();
570 let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
573 for stored in &filed {
574 if stored.envelope.kind == MailKind::User
575 && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
576 {
577 *typed_by_supercode
578 .entry(stored.envelope.body.trim())
579 .or_default() += 1;
580 }
581 }
582 let user = user_address(&address.machine)?;
583 let current: HashSet<String> = mail
587 .typed
588 .iter()
589 .map(|line| typed_line_id(address, line.sent_at_ms, &line.text))
590 .collect();
591 for stored in &filed {
592 if stored.envelope.id.starts_with(TYPED_ID_PREFIX) && !current.contains(&stored.envelope.id)
593 {
594 std::fs::remove_file(&stored.path).ok();
595 }
596 }
597 let mut count = 0;
598 for line in &mail.typed {
599 let id = typed_line_id(address, line.sent_at_ms, &line.text);
600 if ids.contains(id.as_str()) {
601 continue;
602 }
603 if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
604 if *left > 0 {
605 *left -= 1;
606 continue;
607 }
608 }
609 mailbox.file_read(&Envelope {
610 id,
611 created_at_ms: line.sent_at_ms,
612 from: user.clone(),
613 from_name: format!("user@{}", address.machine),
614 kind: MailKind::User,
615 reply_via: ReplyVia::None,
616 in_reply_to: None,
617 in_reply_to_inferred: false,
618 native_from: None,
619 body: line.text.clone(),
620 })?;
621 count += 1;
622 }
623 for line in &mail.channel {
624 let id = channel_line_id(address, &line.header);
625 if ids.contains(id.as_str()) {
626 continue;
627 }
628 mailbox.file_read(&Envelope {
629 id,
630 created_at_ms: line.sent_at_ms,
631 from: MailAddress::new(address.machine.clone(), "operator", "channel")
632 .map_err(|error| std::io::Error::other(error.0))?,
633 from_name: line.from.clone(),
634 kind: MailKind::Channel,
635 reply_via: ReplyVia::None,
636 in_reply_to: None,
637 in_reply_to_inferred: false,
638 native_from: None,
639 body: line.text.clone(),
640 })?;
641 count += 1;
642 }
643 Ok(count)
644}
645
646pub fn file_answers(
650 root: &Path,
651 address: &MailAddress,
652 name: &str,
653 mail: &TranscriptMail,
654) -> std::io::Result<usize> {
655 let mailbox = Mailbox::open(root, &user_address(&address.machine)?)?;
656 let mut filed: HashSet<String> = HashSet::new();
657 for stored in mailbox.list()? {
658 if stored.envelope.id.starts_with(ANSWER_ID_PREFIX)
660 && stored.envelope.kind != MailKind::Answer
661 {
662 std::fs::remove_file(&stored.path).ok();
663 continue;
664 }
665 filed.insert(stored.envelope.id);
666 }
667 let mut count = 0;
668 for answer in &mail.answers {
669 let id = answer_id(address, &answer.native_id);
670 if filed.contains(&id) {
671 continue;
672 }
673 mailbox.file_read(&Envelope {
674 id,
675 created_at_ms: answer.at_ms,
676 from: address.clone(),
677 from_name: name.to_string(),
678 kind: MailKind::Answer,
679 reply_via: ReplyVia::None,
680 in_reply_to: None,
681 in_reply_to_inferred: false,
682 native_from: None,
683 body: answer.text.clone(),
684 })?;
685 count += 1;
686 }
687 Ok(count)
688}
689
690pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
694 let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
695 .into_iter()
696 .filter(|mailbox| mailbox.address() != address)
697 .flat_map(|mailbox| {
698 let to = mailbox.address().clone();
699 mailbox
700 .list()
701 .unwrap_or_default()
702 .into_iter()
703 .filter(|stored| &stored.envelope.from == address)
704 .map(move |stored| (to.clone(), stored))
705 })
706 .collect();
707 sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
708 sent
709}
710
711pub fn deliveries(
714 address: &MailAddress,
715 stored: &[StoredEnvelope],
716 mail: Option<&TranscriptMail>,
717) -> BTreeMap<String, Delivery> {
718 let Some(mail) = mail else {
719 return stored
720 .iter()
721 .map(|stored| (stored.envelope.id.clone(), Delivery::Unknown))
722 .collect();
723 };
724 let typed: HashMap<String, &TypedLine> = mail
725 .typed
726 .iter()
727 .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
728 .collect();
729 let channel: HashMap<String, &ChannelLine> = mail
730 .channel
731 .iter()
732 .map(|line| (channel_line_id(address, &line.header), line))
733 .collect();
734 let mut used: HashSet<usize> = HashSet::new();
737 let mut result = BTreeMap::new();
738 for stored in stored {
739 let envelope = &stored.envelope;
740 if typed.get(&envelope.id).is_some_and(|line| line.withdrawn) {
741 result.insert(envelope.id.clone(), Delivery::Withdrawn);
742 continue;
743 }
744 let at = if let Some(line) = typed.get(&envelope.id) {
745 line.delivered_at_ms
746 } else if let Some(line) = channel.get(&envelope.id) {
747 line.delivered_at_ms
748 } else if let Some(at) = mail
749 .delivered
750 .get(&envelope.id)
751 .or_else(|| mail.delivered.get(crate::mailbox::short_id(&envelope.id)))
752 {
753 Some(*at)
754 } else if envelope.kind == MailKind::User {
755 mail.typed
756 .iter()
757 .enumerate()
758 .find(|(index, line)| {
759 !used.contains(index)
760 && line.sent_at_ms + 1_000 >= envelope.created_at_ms
761 && line.text.trim() == envelope.body.trim()
762 })
763 .and_then(|(index, line)| {
764 used.insert(index);
765 line.delivered_at_ms
766 })
767 } else {
768 None
769 };
770 result.insert(
771 envelope.id.clone(),
772 match at {
773 Some(at_ms) => Delivery::Delivered { at_ms },
774 None => Delivery::Sent,
775 },
776 );
777 }
778 result
779}
780
781pub const MIN_PREFIX_DIGITS: usize = 4;
783
784pub fn find_messages(root: &Path, id: &str) -> std::io::Result<Vec<(Mailbox, StoredEnvelope)>> {
789 let digits = id.split_once('-').map_or(0, |(_, digits)| digits.len());
790 if digits < MIN_PREFIX_DIGITS {
791 return Ok(Vec::new());
792 }
793 let mut found: Vec<(Mailbox, StoredEnvelope)> = Vec::new();
794 for mailbox in crate::mailbox::all_mailboxes(root) {
795 for stored in mailbox.find_prefix(id)? {
796 if !found
797 .iter()
798 .any(|(_, known)| known.envelope.id == stored.envelope.id)
799 {
800 found.push((mailbox.clone(), stored));
801 }
802 }
803 }
804 Ok(found)
805}