use std::io;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::session::transcript::TRANSCRIPT_FILENAME;
pub fn record_dir(state_dir: &str) -> String {
format!("{state_dir}.record")
}
pub fn transcript_path(state_dir: &str) -> PathBuf {
let placed = Path::new(&record_dir(state_dir)).join(TRANSCRIPT_FILENAME);
if placed.is_file() {
return placed;
}
let legacy = Path::new(state_dir).join(TRANSCRIPT_FILENAME);
if legacy.is_file() { legacy } else { placed }
}
pub fn prepare_record_dir(state_dir: &str) -> String {
let directory = record_dir(state_dir);
let _ = std::fs::create_dir_all(&directory);
let placed = Path::new(&directory).join(TRANSCRIPT_FILENAME);
let legacy = Path::new(state_dir).join(TRANSCRIPT_FILENAME);
if !placed.is_file() && legacy.is_file() {
let _ = std::fs::rename(&legacy, &placed);
}
directory
}
pub fn withdraw_from_record(state_dir: &str, message_id: &str) -> io::Result<Option<String>> {
let path = transcript_path(state_dir);
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let mut said: Option<String> = None;
let lines: Vec<String> = text
.split('\n')
.map(|line| {
if line.trim().is_empty() {
return line.to_owned();
}
let Ok(mut parsed) = serde_json::from_str::<Value>(line) else {
return line.to_owned();
};
let checked = parsed.get("entry");
let Some(entry) = checked else {
return line.to_owned();
};
if entry.get("id").and_then(Value::as_str) != Some(message_id) {
return line.to_owned();
}
let call = entry.get("call").and_then(Value::as_str);
if call != Some("prompt") && call != Some("aside") {
return line.to_owned();
}
if let Some(text) = entry.get("text").and_then(Value::as_str)
&& !text.is_empty()
{
said = Some(text.to_owned());
}
let entry = parsed
.get_mut("entry")
.expect("the entry was there a moment ago");
entry["text"] = json!("");
entry["withdrawn"] = json!(true);
serde_json::to_string(&parsed).unwrap_or_else(|_| line.to_owned())
})
.collect();
let Some(said) = said else {
return Ok(None);
};
write_over(&path, &lines.join("\n"))?;
Ok(Some(said))
}
const WITHDRAWN_TEXT: &str = "[a message here was withdrawn by the person who sent it]";
pub fn withdraw_from_agent_session(state_dir: &str, said: &str) -> io::Result<bool> {
let Ok(entries) = std::fs::read_dir(Path::new(state_dir).join("sessions")) else {
return Ok(false);
};
let mut withdrew = false;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() || path.extension().is_none_or(|name| name != "jsonl") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let mut found = false;
let lines: Vec<String> = text
.split('\n')
.map(|line| {
if line.trim().is_empty() {
return line.to_owned();
}
let Ok(mut parsed) = serde_json::from_str::<Value>(line) else {
return line.to_owned();
};
if !carries_withdrawn(&parsed, said) {
return line.to_owned();
}
found = true;
if let Some(message) = parsed.get_mut("message") {
message["content"] = json!([{ "type": "text", "text": WITHDRAWN_TEXT }]);
}
serde_json::to_string(&parsed).unwrap_or_else(|_| line.to_owned())
})
.collect();
if !found {
continue;
}
write_over(&path, &lines.join("\n"))?;
withdrew = true;
}
Ok(withdrew)
}
fn write_over(path: &Path, text: &str) -> io::Result<()> {
let staging = PathBuf::from(format!("{}.withdrawing", path.display()));
std::fs::write(&staging, text)?;
std::fs::rename(&staging, path)
}
fn carries_withdrawn(parsed: &Value, said: &str) -> bool {
let Some(message) = parsed.get("message") else {
return false;
};
if message.get("role").and_then(Value::as_str) != Some("user") {
return false;
}
let Some(content) = message.get("content").and_then(Value::as_array) else {
return false;
};
content.iter().any(|block| {
block
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| text.contains(said))
})
}
#[cfg(test)]
mod tests;