use super::*;
pub(crate) fn build(
records: &[(usize, Record)],
sidecar: &[Record],
) -> (Vec<TurnSlice>, Vec<SummaryInfo>) {
let recs: Vec<&Record> = records.iter().map(|(_, r)| r).collect();
let turns = group_turn_indices_deduped(&recs, |r| *r);
let plan_index = PlanIndex::from_records(recs.iter().copied());
let mut summaries: Vec<SummaryInfo> = Vec::new();
for (line_no, rec) in records {
if rec.is_compact_summary.unwrap_or(false) {
if let Some(body) = compact_summary_body(rec) {
summaries.push(SummaryInfo {
line_no: *line_no,
fingerprints: summary_fingerprints(&body),
body_chars: body.chars().count(),
});
}
}
}
let summary_lines: Vec<usize> = summaries.iter().map(|s| s.line_no).collect();
let mut slices: Vec<TurnSlice> = Vec::with_capacity(turns.len());
for (turn_index, idxs) in turns.iter().enumerate() {
let mut user: Option<TurnUnit> = None;
let mut is_automation = false;
let mut automation: Option<crate::model::AutomationTrigger> = None;
let mut agents: Vec<AgentMsg> = Vec::new();
let mut tool_calls = 0usize;
let mut image_ids: Vec<String> = Vec::new();
let mut pending_tool_calls = 0usize;
let mut pending_failed = 0usize;
for &i in idxs {
let (line_no, rec) = (records[i].0, &records[i].1);
if let Some(blocks) = rec.blocks() {
for b in blocks {
match b {
Block::ToolUse { .. } => {
tool_calls += 1;
pending_tool_calls += 1;
}
Block::ToolResult {
is_error: Some(true),
..
} => {
pending_failed += 1;
}
_ => {}
}
}
}
image_ids.extend(crate::image::image_ids_for_record(rec, line_no));
if user.is_none() && rec.opens_turn() {
if let Some(label) = rec.automation_label() {
is_automation = true;
automation = rec.automation_trigger();
user = Some(make_unit(line_no, Role::User, &label, rec));
} else if let Some(ic) = rec.inbound_comm_preview() {
let mut u = make_unit(line_no, Role::User, &ic.body, rec);
u.inbound = Some(ic);
user = Some(u);
} else if let Some(text) = rec.reconstructed_user_text(Some(&plan_index)) {
user = Some(make_unit(line_no, Role::User, &text, rec));
}
}
if let Some(text) = rec.agent_text() {
agents.push(AgentMsg {
unit: make_unit(line_no, Role::Assistant, &text, rec),
pos: AgentPos::Last,
preceding_tool_calls: pending_tool_calls,
preceding_failed: pending_failed,
});
pending_tool_calls = 0;
pending_failed = 0;
}
}
let last = agents.len().saturating_sub(1);
for (i, a) in agents.iter_mut().enumerate() {
a.pos = if i == last {
AgentPos::Last
} else if i == 0 {
AgentPos::First
} else {
AgentPos::Middle
};
}
let content_line = user
.as_ref()
.map(|u| u.line_no)
.into_iter()
.chain(agents.iter().map(|a| a.unit.line_no))
.max()
.unwrap_or(0);
let compactions_before = summary_lines.iter().filter(|&&s| s > content_line).count();
slices.push(TurnSlice {
turn_index,
user,
tool_calls,
image_ids,
agents,
compactions_before,
is_automation,
automation,
});
}
for rec in sidecar {
let Some(text) = crate::elicitation::pending_text(rec) else {
continue;
};
let turn_index = slices.len();
slices.push(TurnSlice {
turn_index,
user: Some(make_unit(0, Role::User, &text, rec)),
tool_calls: 0,
image_ids: Vec::new(),
agents: Vec::new(),
compactions_before: 0,
is_automation: false,
automation: None,
});
}
(slices, summaries)
}
pub(crate) fn make_unit(line_no: usize, role: Role, text: &str, rec: &Record) -> TurnUnit {
let orig_newlines = raw_body_newlines(rec);
TurnUnit {
line_no,
role,
full_chars: text.chars().count(),
text: text.to_string(),
orig_newlines,
ts_utc: rec.timestamp.clone(),
also_in_summary: false,
from_sidecar: rec.is_elicitation_marker(),
inbound: None,
}
}
pub(crate) fn raw_body_newlines(rec: &Record) -> usize {
let Some(msg) = rec.message.as_ref() else {
return 0;
};
let Some(content) = msg.content.as_ref() else {
return 0;
};
let raw = match content {
Content::Text(s) => s.clone(),
Content::Blocks(blocks) => blocks
.iter()
.filter_map(|b| match b {
Block::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
};
raw.matches('\n').count()
}
pub(crate) fn compact_summary_body(rec: &Record) -> Option<String> {
let content = rec.message.as_ref()?.content.as_ref()?;
match content {
Content::Text(s) => Some(s.clone()),
Content::Blocks(_) => None,
}
}
pub(crate) fn summary_fingerprints(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in body.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("- ") {
let fp = match quoted_inner(rest) {
Some(q) => fingerprint(&q),
None => fingerprint(rest),
};
if !fp.is_empty() {
out.push(fp);
}
} else if let Some(inner) = quoted_inner(trimmed) {
let fp = fingerprint(&inner);
if !fp.is_empty() {
out.push(fp);
}
}
}
out
}
pub(crate) fn quoted_inner(s: &str) -> Option<String> {
let start = s.find('"')?;
let rest = &s[start + 1..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
pub(crate) fn fingerprint(s: &str) -> String {
let normalized = normalize_line(s).to_lowercase();
normalized.chars().take(DEDUP_PREFIX).collect()
}