use saya_agent::{ChatMessage, TokenUsage};
use saya_store::RedactedTurn;
pub(crate) const COMPACT_KEEP_TAIL_TURNS: usize = 2;
pub(crate) const COMPACT_MIN_TURNS: usize = 3;
pub(crate) const COMPACT_SUMMARY_MAX_CHARS: usize = 4096;
pub(crate) const COMPACT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
pub(crate) const COMPACT_MAX_ATTEMPTS: u32 = 2;
pub(crate) struct CompactionPlan {
pub(crate) summarise: Vec<ChatMessage>,
pub(crate) compacted_turns: usize,
pub(crate) pinned: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct CompactionOutcome {
pub(crate) summary: String,
pub(crate) usage: Option<TokenUsage>,
}
pub(crate) fn summarise_prompt(summarise: &[ChatMessage]) -> String {
let mut prompt = String::from(
"Summarise the following earlier conversation turns into a compact \
narrative a database assistant can resume from. Keep facts, decisions, \
and open work; drop chatter. You MUST preserve every pinned token \
below verbatim (exact strings, including size/digest anchors and SQL \
statements) — the session is invalid without them.\n\n",
);
for message in summarise {
prompt.push_str(&format!("{}: {}\n", message.role, message.content));
}
prompt.push_str("\nPinned tokens (preserve every one verbatim):\n");
for token in pinned_tokens(summarise) {
prompt.push_str(&format!("- {token}\n"));
}
prompt
}
pub(crate) fn plan(turns: &[RedactedTurn], history: &[ChatMessage]) -> Option<CompactionPlan> {
if turns.len() <= COMPACT_MIN_TURNS {
return None;
}
let summarised = turns.len() - COMPACT_KEEP_TAIL_TURNS;
let summarise: Vec<ChatMessage> = history.iter().take(summarised * 2).cloned().collect();
if summarise.is_empty() {
return None;
}
let pinned = pinned_tokens(&summarise);
Some(CompactionPlan {
summarise,
compacted_turns: summarised,
pinned,
})
}
pub(crate) fn apply(
state: &mut super::session_state::SessionState,
plan: &CompactionPlan,
summary: &str,
) -> Result<(), String> {
validate(summary, &plan.pinned)?;
state.compaction_summary = Some(summary.to_string());
state.compacted_turns = plan.compacted_turns;
state.context_warned = false;
Ok(())
}
pub(crate) fn validate(summary: &str, pinned: &[String]) -> Result<(), String> {
if summary.trim().is_empty() {
return Err("the summariser returned an empty summary".into());
}
for token in pinned {
if !summary.contains(token) {
return Err("the summary dropped a pinned tool result".into());
}
}
Ok(())
}
pub(crate) fn summary_tokens(summary: &str) -> u64 {
summary.split_whitespace().count() as u64
}
pub(crate) fn success_message(compacted_turns: usize, summary: &str) -> String {
format!(
"Compacted {compacted_turns} turns into a summary (~{} tokens). \
The transcript is unchanged; /export first if you want the full text.",
summary_tokens(summary)
)
}
pub(crate) fn failure_message(reason: &str) -> String {
format!("Compaction failed ({reason}); the conversation is unchanged.")
}
pub(crate) fn pinned_tokens(summarise: &[ChatMessage]) -> Vec<String> {
let mut pinned = Vec::new();
for message in summarise {
for line in message.content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("size:")
|| trimmed.starts_with("digest:")
|| trimmed.starts_with("path:")
|| trimmed.to_lowercase().starts_with("select")
|| trimmed.to_lowercase().starts_with("with")
{
pinned.push(trimmed.to_string());
}
}
}
pinned.sort();
pinned.dedup();
pinned
}