use super::*;
pub fn summarize_session(path: &Path) -> Result<SessionSummary> {
let session_id = crate::subagent::session_id_from_path(path);
let mut first_user: Option<MessagePreview> = None;
let mut cwd: Option<String> = None;
let mut version: Option<String> = None;
let mut git_branch: Option<String> = None;
let mut data_session_id: Option<String> = None;
let mut minted_by_clear = false;
let mut clear_wrapper_ts: Option<String> = None;
let mut saw_first_user_record = false;
let (head_skipped, head_consumed) =
head_records_prefiltered(path, line_is_list_candidate, |rec| {
if !saw_first_user_record {
if let Some(is_mint) = clear_mint_verdict(rec) {
saw_first_user_record = true;
if is_mint {
minted_by_clear = true;
clear_wrapper_ts = rec.timestamp.clone();
}
}
}
if let Some(text) = preview_text(rec) {
cwd = rec.cwd.clone();
version = rec.version.clone();
git_branch = rec.git_branch.clone();
data_session_id = rec.session_id.clone();
first_user = Some(MessagePreview::from(rec.timestamp.clone(), &text));
return false; }
true
})?;
let mut last_user: Option<MessagePreview> = None;
let mut last_agent: Option<MessagePreview> = None;
let mut version_last: Option<String> = None;
let mut git_branch_last: Option<String> = None;
let tail_skipped =
tail_records_prefiltered(path, line_is_list_candidate, head_consumed, |rec| {
if version_last.is_none() {
version_last = rec.version.clone();
}
if git_branch_last.is_none() {
git_branch_last = rec.git_branch.clone();
}
if last_agent.is_none() {
if let Some(text) = rec.agent_text() {
last_agent = Some(MessagePreview::from(rec.timestamp.clone(), &text));
}
}
if last_user.is_none() {
if let Some(text) = preview_text(rec) {
last_user = Some(MessagePreview::from(rec.timestamp.clone(), &text));
capture_identity_if_empty(
rec,
&mut cwd,
&mut version,
&mut git_branch,
&mut data_session_id,
);
}
}
last_user.is_none() || last_agent.is_none()
})?;
let session_id = if session_id.is_empty() {
data_session_id.unwrap_or_default()
} else {
session_id
};
let is_subagent = crate::subagent::is_subagent_path(path);
let parent_session_id =
crate::subagent::parent_session_id_from_path(path).unwrap_or_else(|| session_id.clone());
let mut sidecar_skipped = 0usize;
let pending_elicitations = if is_subagent {
Vec::new()
} else {
let (pending, skipped) = crate::elicitation::unresolved_pending(path)?;
sidecar_skipped = skipped;
pending
.iter()
.filter_map(crate::elicitation::pending_text)
.collect()
};
let sidecar_present =
!is_subagent && crate::elicitation::sidecar_path(path).is_some_and(|p| p.is_file());
let clone_boundary_uuid = if is_subagent {
None
} else {
clone_head_boundary(path)?
};
let clone_of = clone_boundary_uuid
.as_deref()
.and_then(|u| clone_origin(path, u));
let minted_by_clear = minted_by_clear && !is_subagent;
let clear_join = match clear_wrapper_ts.as_deref() {
Some(ts) if minted_by_clear => cleared_from_origin(path, ts),
_ => ClearJoin::default(),
};
Ok(SessionSummary {
session_id,
is_subagent,
parent_session_id,
path: path.to_path_buf(),
cwd,
version: version_last.clone().or_else(|| version.clone()),
version_first: version.or(version_last),
git_branch: git_branch_last.clone().or_else(|| git_branch.clone()),
git_branch_first: git_branch.or(git_branch_last),
first_user,
last_user,
last_agent,
skipped_lines: head_skipped + tail_skipped + sidecar_skipped,
pending_elicitations,
sidecar_present,
clone_boundary_uuid,
clone_of,
minted_by_clear,
cleared_from: clear_join.cleared_from,
cleared_from_distance_ms: clear_join.distance_ms,
cleared_from_after: clear_join.after,
cleared_from_candidates: clear_join.candidates,
})
}
pub(crate) fn clear_mint_verdict(rec: &Record) -> Option<bool> {
(rec.r#type.as_deref() == Some("user") && rec.is_meta != Some(true)).then(|| {
rec.slash_command_name()
.is_some_and(|n| n.trim_start_matches('/') == "clear")
})
}
pub(crate) fn clear_mint_wrapper(path: &Path) -> Result<Option<String>> {
let mut ts: Option<String> = None;
head_records_prefiltered(
path,
line_is_list_candidate,
|rec| match clear_mint_verdict(rec) {
Some(true) => {
ts = rec.timestamp.clone();
false
}
Some(false) => false,
None => true,
},
)?;
Ok(ts)
}
const CLEAR_CHAIN_MAX: usize = 32;
pub(crate) fn cleared_from_root(path: &Path) -> Option<String> {
let mut at = path.to_path_buf();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut root: Option<String> = None;
for _ in 0..CLEAR_CHAIN_MAX {
let Some(ts) = clear_mint_wrapper(&at).ok().flatten() else {
break;
};
let Some(prev) = cleared_from_origin(&at, &ts).cleared_from else {
break;
};
if !seen.insert(prev.clone()) {
break;
}
let next = at.with_file_name(format!("{prev}.jsonl"));
if !next.is_file() {
break;
}
root = Some(prev);
at = next;
}
root
}
pub(crate) const CLEAR_JOIN_WINDOW_MS: i64 = 2000;
#[derive(Debug, Clone, Default)]
pub(crate) struct ClearJoin {
pub(crate) cleared_from: Option<String>,
pub(crate) distance_ms: Option<i64>,
pub(crate) after: bool,
pub(crate) candidates: Vec<String>,
}
pub(crate) fn cleared_from_origin(path: &Path, wrapper_ts: &str) -> ClearJoin {
let mut join = ClearJoin::default();
let Some(t_wrapper) = crate::timez::epoch_ms(wrapper_ts) else {
return join;
};
let Some(dir) = path.parent() else {
return join;
};
let Ok(entries) = std::fs::read_dir(dir) else {
return join;
};
let mut best: Vec<(i64, i64, String)> = Vec::new();
for entry in entries.flatten() {
let sib = entry.path();
if sib == *path
|| sib.extension().and_then(|e| e.to_str()) != Some("jsonl")
|| !sib.is_file()
{
continue;
}
if let Some((dist, delta)) = nearest_checkpoint_close(&sib, t_wrapper) {
if dist <= CLEAR_JOIN_WINDOW_MS {
best.push((dist, delta, crate::subagent::session_id_from_path(&sib)));
}
}
}
best.sort_by(|a, b| (a.0, &a.2).cmp(&(b.0, &b.2)));
let Some((dist, delta, id)) = best.first().cloned() else {
return join;
};
let tied: Vec<String> = best
.iter()
.filter(|(d, _, _)| *d == dist)
.map(|(_, _, i)| i.clone())
.collect();
join.distance_ms = Some(dist);
join.after = delta < 0;
if tied.len() > 1 {
join.candidates = tied;
} else {
join.cleared_from = Some(id);
}
join
}
fn nearest_checkpoint_close(sib: &Path, t_wrapper: i64) -> Option<(i64, i64)> {
static COST: std::sync::LazyLock<memchr::memmem::Finder<'static>> =
std::sync::LazyLock::new(|| memchr::memmem::Finder::new(b"\"cost-state\""));
let mmap = crate::parse::mmap_bytes(sib).ok().flatten()?;
let bytes: &[u8] = &mmap;
let mut at = 0usize;
let mut best: Option<(i64, i64)> = None;
while let Some(pos) = COST.find(&bytes[at..]) {
let abs = at + pos;
let start = memchr::memrchr(b'\n', &bytes[..abs]).map_or(0, |i| i + 1);
let end = memchr::memchr(b'\n', &bytes[abs..]).map_or(bytes.len(), |i| abs + i);
if let Some(close) = checkpoint_close_ms(&bytes[start..end]) {
let delta = t_wrapper - close;
let dist = delta.abs();
if best.is_none_or(|(b, _)| dist < b) {
best = Some((dist, delta));
}
}
at = end.min(bytes.len());
if at >= bytes.len() {
break;
}
}
best
}
pub(crate) fn checkpoint_close_ms(line: &[u8]) -> Option<i64> {
let v: serde_json::Value = serde_json::from_slice(line).ok()?;
if v.get("type").and_then(serde_json::Value::as_str) != Some("cost-state") {
return None;
}
let num = |k: &str| v.get(k).and_then(serde_json::Value::as_f64);
#[allow(clippy::cast_possible_truncation)]
Some((num("startTime")? + num("totalDuration")?) as i64)
}
pub(crate) fn clone_head_boundary(path: &Path) -> Result<Option<String>> {
static TS: std::sync::LazyLock<memchr::memmem::Finder<'static>> =
std::sync::LazyLock::new(|| memchr::memmem::Finder::new(b"\"timestamp\""));
let Some(mmap) = crate::parse::mmap_bytes(path)? else {
return Ok(None);
};
for line in mmap.split(|&b| b == b'\n') {
if TS.find(line).is_none() {
continue;
}
if let Ok(Some(rec)) = crate::parse::parse_line(line) {
if rec.timestamp.is_some() {
let hit = rec.is_compact_boundary();
return Ok(hit
.then(|| rec.uuid.clone().unwrap_or_default())
.filter(|u| !u.is_empty()));
}
}
}
Ok(None)
}
pub(crate) fn clone_origin(path: &Path, boundary_uuid: &str) -> Option<String> {
let dir = path.parent()?;
let finder = memchr::memmem::Finder::new(boundary_uuid.as_bytes());
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let sib = entry.path();
if sib == *path
|| sib.extension().and_then(|e| e.to_str()) != Some("jsonl")
|| !sib.is_file()
{
continue;
}
let Ok(Some(mmap)) = crate::parse::mmap_bytes(&sib) else {
continue;
};
let bytes: &[u8] = &mmap;
let mut at = 0usize;
let mut carrier = false;
while let Some(pos) = finder.find(&bytes[at..]) {
let abs = at + pos;
let start = memchr::memrchr(b'\n', &bytes[..abs]).map_or(0, |i| i + 1);
let end = memchr::memchr(b'\n', &bytes[abs..]).map_or(bytes.len(), |i| abs + i);
if let Ok(Some(rec)) = crate::parse::parse_line(&bytes[start..end]) {
if rec.uuid.as_deref() == Some(boundary_uuid) && rec.is_compact_boundary() {
carrier = true;
break;
}
}
at = end.min(bytes.len());
if at >= bytes.len() {
break;
}
}
if carrier && clone_head_boundary(&sib).ok().flatten().as_deref() != Some(boundary_uuid) {
return Some(crate::subagent::session_id_from_path(&sib));
}
}
None
}
pub(crate) fn capture_identity_if_empty(
rec: &Record,
cwd: &mut Option<String>,
version: &mut Option<String>,
git_branch: &mut Option<String>,
data_session_id: &mut Option<String>,
) {
if cwd.is_none() {
*cwd = rec.cwd.clone();
}
if version.is_none() {
*version = rec.version.clone();
}
if git_branch.is_none() {
*git_branch = rec.git_branch.clone();
}
if data_session_id.is_none() {
*data_session_id = rec.session_id.clone();
}
}