1use crate::errors::Result;
2use crate::prompts::compaction_prompt;
3use crate::providers::{LlmProvider, Message};
4use std::sync::OnceLock;
5use tiktoken_rs::CoreBPE;
6
7fn bpe() -> &'static CoreBPE {
11 static BPE: OnceLock<CoreBPE> = OnceLock::new();
12 BPE.get_or_init(|| tiktoken_rs::cl100k_base().expect("tiktoken cl100k_base failed to load"))
13}
14
15pub fn count_tokens(text: &str) -> u64 {
17 bpe().encode_ordinary(text).len() as u64
18}
19
20fn message_text(msg: &Message) -> String {
24 use crate::providers::{ContentPart, MessageContent};
25 match &msg.content {
26 MessageContent::Text(s) => s.clone(),
27 MessageContent::Parts(parts) => {
28 let mut buf = String::new();
29 for part in parts {
30 match part {
31 ContentPart::Text { text } => buf.push_str(text),
32 ContentPart::ToolUse { name, input, .. } => {
33 buf.push_str(name);
34 buf.push_str(&input.to_string());
35 }
36 ContentPart::ToolResult { content, .. } => {
37 buf.push_str(content);
38 }
39 }
40 }
41 buf
42 }
43 }
44}
45
46pub fn estimate_tokens(messages: &[Message]) -> u64 {
48 messages
50 .iter()
51 .map(|m| count_tokens(&message_text(m)) + 4)
52 .sum()
53}
54
55pub fn should_compact(
58 messages: &[Message],
59 compact_at_tokens: u64,
60 tool_overhead_tokens: u64,
61) -> bool {
62 if compact_at_tokens == 0 {
63 return true;
64 }
65 let used = estimate_tokens(messages) + tool_overhead_tokens;
66 used >= compact_at_tokens
67}
68
69fn trim_to_fit(window: &mut Vec<Message>, target_tokens: u64) {
74 while window.len() > 1 && estimate_tokens(window) > target_tokens {
75 window.remove(1);
76 }
77}
78
79async fn summarize_chunk(
83 chunk: &[Message],
84 provider: &dyn LlmProvider,
85 context_window: u64,
86) -> Result<String> {
87 let prompt_tokens = count_tokens(compaction_prompt()) + 4;
88 let available = (context_window * 90 / 100).saturating_sub(prompt_tokens);
89
90 let mut start = 0usize;
91 while start < chunk.len() {
92 let tokens: u64 = chunk[start..]
93 .iter()
94 .map(|m| count_tokens(&message_text(m)) + 4)
95 .sum();
96 if tokens <= available {
97 break;
98 }
99 start += 1;
100 }
101
102 let mut msgs = chunk[start..].to_vec();
103 msgs.push(Message::user(compaction_prompt()));
104
105 let response = provider.chat(&msgs, &[]).await?;
106 Ok(response
107 .text
108 .unwrap_or_else(|| "Summary unavailable.".to_string()))
109}
110
111pub async fn compact(
123 messages: &[Message],
124 provider: &dyn LlmProvider,
125 keep_recent_turns: usize,
126 compact_to_tokens: u64,
127) -> Result<(Vec<Message>, Vec<Message>)> {
128 let context_window = provider.context_window();
129 let summary_text = summarize_chunk(messages, provider, context_window).await?;
130
131 let recent_start = messages.len().saturating_sub(keep_recent_turns * 2);
132 let mut new_window = Vec::new();
133 new_window.push(Message::user(format!(
134 "## Compacted Session Summary\n\n{}\n\n---\nContinuing from where we left off.",
135 summary_text
136 )));
137 new_window.extend_from_slice(&messages[recent_start..]);
138
139 trim_to_fit(&mut new_window, compact_to_tokens);
140
141 Ok((new_window, messages.to_vec()))
142}
143
144pub async fn compact_split(
159 messages: &[Message],
160 provider: &dyn LlmProvider,
161 keep_recent_turns: usize,
162 compact_to_tokens: u64,
163) -> Result<(Vec<Message>, Vec<Message>)> {
164 let context_window = provider.context_window();
165 let recent_start = messages.len().saturating_sub(keep_recent_turns * 2);
166 let recent = &messages[recent_start..];
167 let old = &messages[..recent_start];
168
169 let (summary_1, summary_2) = if old.len() < 2 {
171 let s = summarize_chunk(messages, provider, context_window).await?;
172 (s, String::new())
173 } else {
174 let mid = old.len() / 2;
175 let (s1, s2) = tokio::try_join!(
176 summarize_chunk(&old[..mid], provider, context_window),
177 summarize_chunk(&old[mid..], provider, context_window),
178 )?;
179 (s1, s2)
180 };
181
182 let combined = if summary_2.is_empty() {
183 summary_1
184 } else {
185 format!(
186 "## Part 1 (earliest history)\n\n{}\n\n## Part 2 (continued)\n\n{}",
187 summary_1, summary_2
188 )
189 };
190
191 let mut new_window = Vec::new();
192 new_window.push(Message::user(format!(
193 "## Compacted Session Summary\n\n{}\n\n---\nContinuing from where we left off.",
194 combined
195 )));
196 new_window.extend_from_slice(recent);
197
198 trim_to_fit(&mut new_window, compact_to_tokens);
199
200 Ok((new_window, messages.to_vec()))
201}
202
203pub fn archive(session_dir: &std::path::Path, original: &[Message]) -> Result<()> {
207 let archive_path = session_dir.join("compacted.json");
208 let mut existing: Vec<Vec<Message>> = if archive_path.exists() {
209 let s = std::fs::read_to_string(&archive_path).unwrap_or_default();
210 serde_json::from_str(&s).unwrap_or_default()
211 } else {
212 Vec::new()
213 };
214 existing.push(original.to_vec());
215 let content = serde_json::to_string_pretty(&existing)?;
216 std::fs::write(&archive_path, content)?;
217 Ok(())
218}