use crate::errors::Result;
use crate::prompts::compaction_prompt;
use crate::providers::{LlmProvider, Message};
use std::sync::OnceLock;
use tiktoken_rs::CoreBPE;
fn bpe() -> &'static CoreBPE {
static BPE: OnceLock<CoreBPE> = OnceLock::new();
BPE.get_or_init(|| tiktoken_rs::cl100k_base().expect("tiktoken cl100k_base failed to load"))
}
pub fn count_tokens(text: &str) -> u64 {
bpe().encode_ordinary(text).len() as u64
}
fn message_text(msg: &Message) -> String {
use crate::providers::{ContentPart, MessageContent};
match &msg.content {
MessageContent::Text(s) => s.clone(),
MessageContent::Parts(parts) => {
let mut buf = String::new();
for part in parts {
match part {
ContentPart::Text { text } => buf.push_str(text),
ContentPart::ToolUse { name, input, .. } => {
buf.push_str(name);
buf.push_str(&input.to_string());
}
ContentPart::ToolResult { content, .. } => {
buf.push_str(content);
}
}
}
buf
}
}
}
pub fn estimate_tokens(messages: &[Message]) -> u64 {
messages
.iter()
.map(|m| count_tokens(&message_text(m)) + 4)
.sum()
}
pub fn should_compact(
messages: &[Message],
compact_at_tokens: u64,
tool_overhead_tokens: u64,
) -> bool {
if compact_at_tokens == 0 {
return true;
}
let used = estimate_tokens(messages) + tool_overhead_tokens;
used >= compact_at_tokens
}
fn trim_to_fit(window: &mut Vec<Message>, target_tokens: u64) {
while window.len() > 1 && estimate_tokens(window) > target_tokens {
window.remove(1);
}
}
async fn summarize_chunk(
chunk: &[Message],
provider: &dyn LlmProvider,
context_window: u64,
) -> Result<String> {
let prompt_tokens = count_tokens(compaction_prompt()) + 4;
let available = (context_window * 90 / 100).saturating_sub(prompt_tokens);
let mut start = 0usize;
while start < chunk.len() {
let tokens: u64 = chunk[start..]
.iter()
.map(|m| count_tokens(&message_text(m)) + 4)
.sum();
if tokens <= available {
break;
}
start += 1;
}
let mut msgs = chunk[start..].to_vec();
msgs.push(Message::user(compaction_prompt()));
let response = provider.chat(&msgs, &[]).await?;
Ok(response
.text
.unwrap_or_else(|| "Summary unavailable.".to_string()))
}
pub async fn compact(
messages: &[Message],
provider: &dyn LlmProvider,
keep_recent_turns: usize,
compact_to_tokens: u64,
) -> Result<(Vec<Message>, Vec<Message>)> {
let context_window = provider.context_window();
let summary_text = summarize_chunk(messages, provider, context_window).await?;
let recent_start = messages.len().saturating_sub(keep_recent_turns * 2);
let mut new_window = Vec::new();
new_window.push(Message::user(format!(
"## Compacted Session Summary\n\n{}\n\n---\nContinuing from where we left off.",
summary_text
)));
new_window.extend_from_slice(&messages[recent_start..]);
trim_to_fit(&mut new_window, compact_to_tokens);
Ok((new_window, messages.to_vec()))
}
pub async fn compact_split(
messages: &[Message],
provider: &dyn LlmProvider,
keep_recent_turns: usize,
compact_to_tokens: u64,
) -> Result<(Vec<Message>, Vec<Message>)> {
let context_window = provider.context_window();
let recent_start = messages.len().saturating_sub(keep_recent_turns * 2);
let recent = &messages[recent_start..];
let old = &messages[..recent_start];
let (summary_1, summary_2) = if old.len() < 2 {
let s = summarize_chunk(messages, provider, context_window).await?;
(s, String::new())
} else {
let mid = old.len() / 2;
let (s1, s2) = tokio::try_join!(
summarize_chunk(&old[..mid], provider, context_window),
summarize_chunk(&old[mid..], provider, context_window),
)?;
(s1, s2)
};
let combined = if summary_2.is_empty() {
summary_1
} else {
format!(
"## Part 1 (earliest history)\n\n{}\n\n## Part 2 (continued)\n\n{}",
summary_1, summary_2
)
};
let mut new_window = Vec::new();
new_window.push(Message::user(format!(
"## Compacted Session Summary\n\n{}\n\n---\nContinuing from where we left off.",
combined
)));
new_window.extend_from_slice(recent);
trim_to_fit(&mut new_window, compact_to_tokens);
Ok((new_window, messages.to_vec()))
}
pub fn archive(session_dir: &std::path::Path, original: &[Message]) -> Result<()> {
let archive_path = session_dir.join("compacted.json");
let mut existing: Vec<Vec<Message>> = if archive_path.exists() {
let s = std::fs::read_to_string(&archive_path).unwrap_or_default();
serde_json::from_str(&s).unwrap_or_default()
} else {
Vec::new()
};
existing.push(original.to_vec());
let content = serde_json::to_string_pretty(&existing)?;
std::fs::write(&archive_path, content)?;
Ok(())
}