use super::*;
pub(crate) fn is_comm_class(c: Class) -> bool {
matches!(c, Class::CommInbox | Class::CommSent | Class::CommSignal)
}
pub(crate) fn alias_self(
dir: Option<(String, String)>,
owner_id: Option<&str>,
) -> Option<(String, String)> {
let Some(owner) = owner_id else {
return dir;
};
dir.map(|(from, to)| {
let sub = |s: String| if s == owner { "self".to_string() } else { s };
(sub(from), sub(to))
})
}
pub(crate) fn is_record_text_class(c: Class) -> bool {
matches!(
c,
Class::UserMessage
| Class::UserUnsent
| Class::CommInbox
| Class::CommSignal
| Class::NotificationWorkflow
| Class::NotificationMonitor
| Class::NotificationSubagent
| Class::NotificationBackgroundCommand
| Class::NotificationTask
| Class::CompactionSummary
| Class::CompactionBoundary
| Class::CommandInvocation
| Class::CommandStdout
| Class::InterruptUser
| Class::InterruptTool
| Class::ScheduleWakeup
| Class::ScheduleContinuation
| Class::MetaHook
| Class::MetaLoop
| Class::MetaAttachment
| Class::UserQueued
| Class::MetaTurnDuration
| Class::MetaAwaySummary
| Class::MetaStopHooks
| Class::MetaSnapshot
)
}
pub(crate) fn record_text_emission(
rec: &Record,
labels: &[Class],
filter: LabelFilter<'_>,
plan_index: &PlanIndex,
) -> Option<(Class, String)> {
for &c in labels {
if !is_record_text_class(c) || !filter.selected(c.path()) {
continue;
}
let text = match c {
Class::NotificationWorkflow
| Class::NotificationMonitor
| Class::NotificationSubagent
| Class::NotificationBackgroundCommand
| Class::NotificationTask => rec.automation_label(),
Class::UserMessage | Class::UserUnsent | Class::CommInbox => {
rec.reconstructed_user_text(Some(plan_index))
}
Class::CommSignal => rec
.reconstructed_user_text(Some(plan_index))
.or_else(|| record_raw_text(rec)),
_ => record_raw_text(rec),
};
if let Some(text) = text {
return Some((c, text));
}
}
None
}
pub(crate) fn record_raw_text(rec: &Record) -> Option<String> {
if let Some(text) = rec.hook_additional_context_text() {
return Some(text);
}
if let Some(text) = rec.attachment_payload_text() {
return Some(text);
}
if let Some(text) = promoted_record_text(rec) {
return Some(text);
}
let Some(msg) = rec.message.as_ref() else {
return system_record_text(rec);
};
match msg.content.as_ref()? {
Content::Text(s) => Some(s.clone()),
Content::Blocks(blocks) => {
let parts: Vec<&str> = blocks
.iter()
.filter_map(|b| match b {
Block::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
(!parts.is_empty()).then(|| parts.join("\n"))
}
}
}
pub(crate) fn system_record_text(rec: &Record) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(serde_json::Value::String(s)) = rec.content.as_ref() {
let s = s.trim();
if !s.is_empty() {
parts.push(s.to_string());
}
}
if let Some(excerpt) = rec
.compact_metadata
.as_ref()
.and_then(compact_metadata_excerpt)
{
parts.push(excerpt);
}
if rec.is_compact_boundary() {
if let Some(lp) = rec.logical_parent_uuid.as_deref() {
parts.push(format!("[logicalParent={lp}]"));
}
}
(!parts.is_empty()).then(|| parts.join(" "))
}
pub(crate) fn promoted_record_text(rec: &Record) -> Option<String> {
match rec.promoted_class()? {
Class::UserQueued | Class::MetaAwaySummary => rec.content_str().map(str::to_string),
Class::MetaTurnDuration => {
let mut fields: Vec<String> = Vec::new();
let ms = Record::u64_field(rec.duration_ms.as_ref());
if let Some(ms) = ms {
fields.push(format!("{} · durationMs={ms}", fmt_ms(ms)));
}
for (key, v) in [
("messageCount", rec.message_count.as_ref()),
(
"pendingBackgroundAgentCount",
rec.pending_background_agent_count.as_ref(),
),
("pendingWorkflowCount", rec.pending_workflow_count.as_ref()),
] {
if let Some(n) = Record::u64_field(v) {
fields.push(format!("{key}={n}"));
}
}
(!fields.is_empty()).then(|| format!("[turn duration: {}]", fields.join(" ")))
}
Class::MetaStopHooks => {
let count = Record::u64_field(rec.hook_count.as_ref()).unwrap_or(0);
let errors = rec
.hook_errors
.as_ref()
.and_then(serde_json::Value::as_array)
.map_or(0, Vec::len);
let prevented = rec.prevented_continuation.unwrap_or(false);
let mut lines = vec![format!(
"[stop hooks: count={count} errors={errors} prevented={prevented}]"
)];
if let Some(infos) = rec
.hook_infos
.as_ref()
.and_then(serde_json::Value::as_array)
{
for info in infos {
let Some(cmd) = info.get("command").and_then(serde_json::Value::as_str) else {
continue;
};
match Record::u64_field(info.get("durationMs")) {
Some(ms) => lines.push(format!("{cmd} ({ms}ms)")),
None => lines.push(cmd.to_string()),
}
}
}
Some(lines.join("\n"))
}
Class::MetaSnapshot => snapshot_record_text(rec),
_ => None,
}
}
fn snapshot_record_text(rec: &Record) -> Option<String> {
let ver = |v: Option<&serde_json::Value>| {
Record::u64_field(v).map_or_else(|| "?".to_string(), |n| n.to_string())
};
if rec.is_type("file-history-delta") {
let path = rec.tracking_path.as_deref()?;
let backup = rec.backup.as_ref();
let version = ver(backup.and_then(|b| b.get("version")));
let at = rec.timestamp.as_deref().unwrap_or("?");
let name = backup
.and_then(|b| b.get("backupFileName"))
.and_then(serde_json::Value::as_str)
.map(|n| format!(" backup={n}"))
.unwrap_or_default();
return Some(format!(
"[file-history delta at {at}: {path}@v{version}{name}]"
));
}
let snap = rec.snapshot.as_ref()?;
let at = snap
.get("timestamp")
.and_then(serde_json::Value::as_str)
.unwrap_or("?");
let mut entries: Vec<String> = snap
.get("trackedFileBackups")
.and_then(serde_json::Value::as_object)
.map(|m| {
m.iter()
.map(|(path, e)| format!("{path}@v{}", ver(e.get("version"))))
.collect()
})
.unwrap_or_default();
entries.sort_unstable();
Some(format!(
"[file-history snapshot at {at}: {}]",
entries.join(", ")
))
}
pub(crate) use crate::text::fmt_ms;
pub(crate) fn compact_metadata_excerpt(meta: &serde_json::Value) -> Option<String> {
let obj = meta.as_object()?;
let mut fields: Vec<String> = Vec::new();
for key in ["trigger", "preTokens", "postTokens", "durationMs"] {
if let Some(v) = obj.get(key) {
let rendered = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
fields.push(format!("{key}={rendered}"));
}
}
(!fields.is_empty()).then(|| format!("[compaction boundary: {}]", fields.join(" ")))
}
pub(crate) fn tool_use_comm_class(
name: Option<&str>,
input: Option<&serde_json::Value>,
) -> Option<Class> {
match name? {
"SendMessage" => Some(if send_message_is_signal(input) {
Class::CommSignal
} else {
Class::CommSent
}),
n if is_spawn_tool_name(n) => Some(Class::CommSent),
_ => None,
}
}
pub(crate) fn is_spawn_tool_name(name: &str) -> bool {
matches!(name, "Task" | "Agent" | "Workflow")
}
pub(crate) fn send_message_is_signal(input: Option<&serde_json::Value>) -> bool {
let Some(input) = input else {
return false;
};
let type_at = |v: &serde_json::Value| {
v.get("type")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
};
if let Some(t) = type_at(input) {
return !matches!(t.as_str(), "message" | "direct");
}
if let Some(t) = input.get("message").and_then(type_at) {
return !matches!(t.as_str(), "message" | "direct");
}
false
}
pub(crate) fn render_tool_use(name: Option<&str>, input: Option<&serde_json::Value>) -> String {
let mut s = String::new();
if let Some(n) = name {
s.push_str(n);
}
if let Some(v) = input {
s.push(' ');
s.push_str(&v.to_string());
}
s
}
pub(crate) fn resolve_persisted_text(path: &str, inline: &str) -> String {
match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => format!("{inline}\n[csift: could not resolve persisted output {path}: {e}]"),
}
}
#[cfg(test)]
pub(crate) fn auq_answer_text(rec: &Record) -> Option<String> {
let blocks = rec.blocks()?;
for b in blocks {
if let Block::ToolResult {
content: Some(c), ..
} = b
{
let t = tool_result_content_text(c);
if crate::model::is_auq_answer_text(&t) {
return Some(t);
}
}
}
None
}
#[cfg(test)]
pub(crate) fn truncate_excerpt(s: &str) -> String {
crate::text::truncate_excerpt(s, EXCERPT_MAX)
}
pub(crate) fn match_excerpt(
text: &str,
span: Option<(usize, usize)>,
max: usize,
) -> (String, bool) {
let total = text.chars().count();
let head_form = |text: &str| -> (String, bool) {
let norm = normalize_line(text);
let truncated = norm.chars().count() > max;
(crate::text::truncate_excerpt(&norm, max), truncated)
};
let start_byte = match span {
Some((s, _)) if total > max => s,
_ => return head_form(text),
};
let Some(prefix) = text.get(..start_byte) else {
return head_form(text);
};
let match_char = prefix.chars().count();
let win_start = match_char.saturating_sub(max / 4);
let window: String = text.chars().skip(win_start).take(max).collect();
let body = normalize_line(&window);
let after = total.saturating_sub(win_start + max);
let mut out = String::new();
if win_start > 0 {
out.push('…');
}
out.push_str(&body);
if after > 0 {
out.push_str(&format!("… (+{after} chars)"));
}
(out, true)
}
pub(crate) fn parse_turn_range(s: &str) -> Result<crate::text::RangeSpec> {
crate::text::parse_range_spec(s, "--turn", false)
}