use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::mailbox::{Envelope, MailAddress, MailKind, Mailbox, ReplyVia, StoredEnvelope};
use crate::HarnessHomes;
pub const TYPED_ID_PREFIX: &str = "u-";
pub const ANSWER_ID_PREFIX: &str = "a-";
pub fn answer_id(address: &MailAddress, native_id: &str) -> String {
let hash = blake3::hash(format!("{address}\n{native_id}").as_bytes()).to_hex();
format!("{ANSWER_ID_PREFIX}{}", &hash[..24])
}
pub fn user_address(machine: &str) -> std::io::Result<MailAddress> {
MailAddress::new(machine.to_string(), "operator", "user")
.map_err(|error| std::io::Error::other(error.0))
}
pub fn typed_line_id(address: &MailAddress, sent_at_ms: u64, text: &str) -> String {
let hash = blake3::hash(format!("{address}\n{sent_at_ms}\n{text}").as_bytes()).to_hex();
format!("{TYPED_ID_PREFIX}{}", &hash[..24])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypedLine {
pub sent_at_ms: u64,
pub delivered_at_ms: Option<u64>,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Answer {
pub native_id: String,
pub at_ms: u64,
pub text: String,
}
#[derive(Debug, Clone, Default)]
pub struct TranscriptMail {
pub typed: Vec<TypedLine>,
pub answers: Vec<Answer>,
pub delivered: HashMap<String, u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Delivery {
Delivered {
at_ms: u64,
},
Sent,
Unknown,
}
impl Delivery {
pub fn describe(&self) -> String {
match self {
Self::Delivered { at_ms } => {
supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
}
Self::Sent => "not yet".to_string(),
Self::Unknown => "unknown".to_string(),
}
}
}
pub fn transcript(homes: &HarnessHomes, address: &MailAddress) -> Option<PathBuf> {
if address.harness != "claude-code" {
return None;
}
let file = format!("{}.jsonl", address.session_id);
std::fs::read_dir(&homes.claude_code)
.ok()?
.flatten()
.map(|project| project.path().join(&file))
.find(|path| path.is_file())
}
fn at_ms(value: &serde_json::Value) -> u64 {
value
.as_str()
.and_then(supercode_interchange::sidecar::rfc3339_to_ms)
.and_then(|at| u64::try_from(at).ok())
.unwrap_or_default()
}
fn prompt_text(content: &serde_json::Value) -> Option<String> {
let text = match content {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Array(parts) => parts
.iter()
.filter(|part| part["type"] == "text")
.filter_map(|part| part["text"].as_str())
.collect::<Vec<_>>()
.join("\n"),
_ => return None,
};
(!text.trim().is_empty()).then_some(text)
}
fn rendered_mail_ids(line: &str) -> Vec<&str> {
let mut ids = Vec::new();
for mark in ["cross-session-message id=\\\"", "(message "] {
for (at, _) in line.match_indices(mark) {
let rest = &line[at + mark.len()..];
let end = rest
.find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
.unwrap_or(rest.len());
let id = &rest[..end];
if id.starts_with("m-") {
ids.push(id);
}
}
}
ids
}
pub fn read_claude(path: &Path) -> std::io::Result<TranscriptMail> {
let reader = std::io::BufReader::new(std::fs::File::open(path)?);
let mut mail = TranscriptMail::default();
let mut queued: VecDeque<(String, u64)> = VecDeque::new();
let mut absorbed: VecDeque<(String, u64)> = VecDeque::new();
let take = |waiting: &mut VecDeque<(String, u64)>, text: &str| {
let at = waiting.iter().position(|(words, _)| words == text)?;
waiting.remove(at).map(|(_, when)| when)
};
for line in reader.lines() {
let line = line?;
let prompt = line.contains("\"promptSource\"");
let queue = line.contains("\"queue-operation\"");
let attachment = line.contains("\"queued_command\"");
let answer = line.contains("\"stop_reason\":\"end_turn\"")
|| line.contains("\"stop_reason\":\"stop_sequence\"");
let rendered = line.contains("cross-session-message id=") || line.contains("(message m-");
if !(prompt || queue || attachment || rendered || answer) {
continue;
}
let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if record["isSidechain"] == true {
continue;
}
let kind = record["type"].as_str().unwrap_or_default();
let at = at_ms(&record["timestamp"]);
if rendered && matches!(kind, "user" | "attachment") {
for id in rendered_mail_ids(&line) {
mail.delivered.entry(id.to_string()).or_insert(at);
}
}
match kind {
"assistant"
if answer
&& matches!(
record["message"]["stop_reason"].as_str(),
Some("end_turn" | "stop_sequence")
) =>
{
let (Some(native_id), Some(text)) = (
record["uuid"].as_str(),
prompt_text(&record["message"]["content"]),
) else {
continue;
};
mail.answers.push(Answer {
native_id: native_id.to_string(),
at_ms: at,
text,
});
}
"queue-operation" => {
let Some(text) = record["content"].as_str() else {
continue;
};
match record["operation"].as_str() {
Some("enqueue") => queued.push_back((text.to_string(), at)),
Some("remove") => {
if take(&mut queued, text).is_some() {
absorbed.push_back((text.to_string(), at));
}
}
_ => {}
}
}
"attachment" => {
let attached = &record["attachment"];
let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
let Some(text) = attached["prompt"].as_str() else {
continue;
};
if attached["type"] != "queued_command" || !human || text.trim().is_empty() {
continue;
}
let delivered = take(&mut absorbed, text).unwrap_or(at);
mail.typed.push(TypedLine {
sent_at_ms: at_ms(&attached["timestamp"]).min(delivered),
delivered_at_ms: Some(delivered),
text: text.to_string(),
});
}
"user" => {
let source = record["promptSource"].as_str();
if !matches!(source, Some("typed" | "queued")) || record["isMeta"] == true {
continue;
}
let Some(text) = prompt_text(&record["message"]["content"]) else {
continue;
};
let sent = match source {
Some("queued") => take(&mut queued, &text).unwrap_or(at),
_ => at,
};
mail.typed.push(TypedLine {
sent_at_ms: sent,
delivered_at_ms: Some(at),
text,
});
}
_ => {}
}
}
Ok(mail)
}
pub fn read(homes: &HarnessHomes, address: &MailAddress) -> Option<TranscriptMail> {
read_claude(&transcript(homes, address)?).ok()
}
pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
let address = mailbox.address();
let filed = mailbox.list()?;
let ids: HashSet<&str> = filed
.iter()
.map(|stored| stored.envelope.id.as_str())
.collect();
let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
for stored in &filed {
if stored.envelope.kind == MailKind::User
&& !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
{
*typed_by_supercode
.entry(stored.envelope.body.trim())
.or_default() += 1;
}
}
let user = user_address(&address.machine)?;
let mut count = 0;
for line in &mail.typed {
let id = typed_line_id(address, line.sent_at_ms, &line.text);
if ids.contains(id.as_str()) {
continue;
}
if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
if *left > 0 {
*left -= 1;
continue;
}
}
mailbox.file_read(&Envelope {
id,
created_at_ms: line.sent_at_ms,
from: user.clone(),
from_name: format!("user@{}", address.machine),
kind: MailKind::User,
reply_via: ReplyVia::None,
in_reply_to: None,
in_reply_to_inferred: false,
native_from: None,
body: line.text.clone(),
})?;
count += 1;
}
Ok(count)
}
pub fn file_answers(
root: &Path,
address: &MailAddress,
name: &str,
mail: &TranscriptMail,
) -> std::io::Result<usize> {
let mailbox = Mailbox::open(root, &user_address(&address.machine)?)?;
let filed: HashSet<String> = mailbox
.list()?
.into_iter()
.map(|stored| stored.envelope.id)
.collect();
let mut count = 0;
for answer in &mail.answers {
let id = answer_id(address, &answer.native_id);
if filed.contains(&id) {
continue;
}
mailbox.file_read(&Envelope {
id,
created_at_ms: answer.at_ms,
from: address.clone(),
from_name: name.to_string(),
kind: MailKind::Peer,
reply_via: ReplyVia::None,
in_reply_to: None,
in_reply_to_inferred: false,
native_from: None,
body: answer.text.clone(),
})?;
count += 1;
}
Ok(count)
}
pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
.into_iter()
.filter(|mailbox| mailbox.address() != address)
.flat_map(|mailbox| {
let to = mailbox.address().clone();
mailbox
.list()
.unwrap_or_default()
.into_iter()
.filter(|stored| &stored.envelope.from == address)
.map(move |stored| (to.clone(), stored))
})
.collect();
sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
sent
}
pub fn deliveries(
address: &MailAddress,
stored: &[StoredEnvelope],
mail: Option<&TranscriptMail>,
) -> BTreeMap<String, Delivery> {
let Some(mail) = mail else {
return stored
.iter()
.map(|stored| (stored.envelope.id.clone(), Delivery::Unknown))
.collect();
};
let typed: HashMap<String, &TypedLine> = mail
.typed
.iter()
.map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
.collect();
let mut used: HashSet<usize> = HashSet::new();
let mut result = BTreeMap::new();
for stored in stored {
let envelope = &stored.envelope;
let at = if let Some(line) = typed.get(&envelope.id) {
line.delivered_at_ms
} else if let Some(at) = mail.delivered.get(&envelope.id) {
Some(*at)
} else if envelope.kind == MailKind::User {
mail.typed
.iter()
.enumerate()
.find(|(index, line)| {
!used.contains(index)
&& line.sent_at_ms + 1_000 >= envelope.created_at_ms
&& line.text.trim() == envelope.body.trim()
})
.and_then(|(index, line)| {
used.insert(index);
line.delivered_at_ms
})
} else {
None
};
result.insert(
envelope.id.clone(),
match at {
Some(at_ms) => Delivery::Delivered { at_ms },
None => Delivery::Sent,
},
);
}
result
}
pub fn find_message(root: &Path, id: &str) -> std::io::Result<Option<(Mailbox, StoredEnvelope)>> {
for mailbox in crate::mailbox::all_mailboxes(root) {
if let Some(stored) = mailbox.find(id)? {
return Ok(Some((mailbox, stored)));
}
}
Ok(None)
}