use crate::services::compact::microcompact::estimate_message_tokens;
use crate::services::compact::prompt::get_compact_user_summary_message;
use crate::types::{Message, MessageRole};
use crate::utils::env_utils;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Clone)]
pub struct SessionMemoryCompactConfig {
pub min_tokens: usize,
pub min_text_block_messages: usize,
pub max_tokens: usize,
}
impl Default for SessionMemoryCompactConfig {
fn default() -> Self {
Self {
min_tokens: 10_000,
min_text_block_messages: 5,
max_tokens: 40_000,
}
}
}
static SM_COMPACT_CONFIG: std::sync::LazyLock<std::sync::Mutex<SessionMemoryCompactConfig>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(SessionMemoryCompactConfig::default()));
static CONFIG_INITIALIZED: AtomicBool = AtomicBool::new(false);
pub fn get_session_memory_compact_config() -> SessionMemoryCompactConfig {
SM_COMPACT_CONFIG.lock().unwrap().clone()
}
pub fn should_use_session_memory_compaction() -> bool {
if env_utils::is_env_truthy(
std::env::var("ENABLE_CLAUDE_CODE_SM_COMPACT")
.ok()
.as_deref(),
) {
return true;
}
if env_utils::is_env_truthy(
std::env::var("DISABLE_CLAUDE_CODE_SM_COMPACT")
.ok()
.as_deref(),
) {
return false;
}
false
}
pub fn has_text_blocks(message: &Message) -> bool {
match &message.role {
MessageRole::Assistant => !message.content.is_empty(),
MessageRole::User => !message.content.is_empty(),
_ => false,
}
}
pub fn is_compact_boundary_message(message: &Message) -> bool {
matches!(message.role, MessageRole::System)
&& (message
.content
.contains("[Previous conversation summarized]")
|| message.content.contains("compacted")
|| message.content.contains("summarized"))
}
fn get_tool_result_ids(message: &Message) -> Vec<String> {
if !matches!(message.role, MessageRole::Tool) {
return Vec::new();
}
message.tool_call_id.clone().into_iter().collect()
}
fn has_tool_use_with_ids(
message: &Message,
tool_use_ids: &std::collections::HashSet<String>,
) -> bool {
if !matches!(message.role, MessageRole::Assistant) {
return false;
}
if let Some(tool_calls) = &message.tool_calls {
for tc in tool_calls {
if tool_use_ids.contains(&tc.id) {
return true;
}
}
}
false
}
pub fn adjust_index_to_preserve_api_invariants(messages: &[Message], start_index: usize) -> usize {
if start_index <= 0 || start_index >= messages.len() {
return start_index;
}
let mut adjusted_index = start_index;
let all_tool_result_ids: std::collections::HashSet<String> = messages[start_index..]
.iter()
.flat_map(get_tool_result_ids)
.collect();
if !all_tool_result_ids.is_empty() {
let tool_use_ids_in_kept_range: std::collections::HashSet<String> = messages[start_index..]
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.flat_map(|m| m.tool_calls.iter().flatten().map(|tc| tc.id.clone()))
.collect();
let needed_tool_use_ids: std::collections::HashSet<String> = all_tool_result_ids
.difference(&tool_use_ids_in_kept_range)
.cloned()
.collect();
for i in (0..adjusted_index).rev() {
if has_tool_use_with_ids(&messages[i], &needed_tool_use_ids) {
adjusted_index = i;
if let Some(tool_calls) = &messages[i].tool_calls {
for tc in tool_calls {
if needed_tool_use_ids.contains(&tc.id) {
}
}
}
}
}
}
adjusted_index
}
pub fn calculate_messages_to_keep_index(
messages: &[Message],
last_summarized_index: usize,
) -> usize {
if messages.is_empty() {
return 0;
}
let config = get_session_memory_compact_config();
let mut start_index = if last_summarized_index < messages.len() {
last_summarized_index + 1
} else {
messages.len()
};
let mut total_tokens = 0;
let mut text_block_message_count = 0;
for i in start_index..messages.len() {
total_tokens += estimate_message_tokens(&[messages[i].clone()]);
if has_text_blocks(&messages[i]) {
text_block_message_count += 1;
}
}
if total_tokens >= config.max_tokens {
return adjust_index_to_preserve_api_invariants(messages, start_index);
}
if total_tokens >= config.min_tokens
&& text_block_message_count >= config.min_text_block_messages
{
return adjust_index_to_preserve_api_invariants(messages, start_index);
}
let floor = messages
.iter()
.rposition(|m| is_compact_boundary_message(m))
.map(|idx| idx + 1)
.unwrap_or(0);
let mut i = if start_index > 0 { start_index - 1 } else { 0 };
loop {
if i < floor {
break;
}
let msg = &messages[i];
let msg_tokens = estimate_message_tokens(&[msg.clone()]);
total_tokens += msg_tokens;
if has_text_blocks(msg) {
text_block_message_count += 1;
}
start_index = i;
if total_tokens >= config.max_tokens {
break;
}
if total_tokens >= config.min_tokens
&& text_block_message_count >= config.min_text_block_messages
{
break;
}
if i == 0 {
break;
}
i -= 1;
}
adjust_index_to_preserve_api_invariants(messages, start_index)
}
fn get_session_memory_template() -> &'static str {
r#"# Session Notes
This file contains automatically extracted notes about the current conversation.
## Key Points
-
## Decisions Made
-
## Open Items
-
## Context
"#
}
fn is_session_memory_empty(content: &str) -> bool {
let template = get_session_memory_template();
content.trim() == template.trim()
}
const MAX_SECTION_LENGTH: usize = 2000;
const MAX_CHARS_PER_SECTION: usize = MAX_SECTION_LENGTH * 4;
fn truncate_session_memory_for_compact(content: &str) -> (String, bool) {
let mut result = String::new();
let mut was_truncated = false;
let mut current_section: Vec<String> = Vec::new();
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
if line.starts_with('#') && !line.starts_with("## ") {
if !current_section.is_empty() {
flush_section(¤t_section, &mut result, &mut was_truncated);
}
current_section = vec![line.to_string()];
} else {
current_section.push(line.to_string());
}
}
if !current_section.is_empty() {
flush_section(¤t_section, &mut result, &mut was_truncated);
}
(result, was_truncated)
}
fn flush_section(lines: &[String], result: &mut String, was_truncated: &mut bool) {
let joined = lines.join("\n");
if joined.len() <= MAX_CHARS_PER_SECTION {
result.push_str(&joined);
result.push('\n');
} else {
result.push_str(&joined[..MAX_CHARS_PER_SECTION]);
result.push_str("\n[... section truncated for length ...]\n");
*was_truncated = true;
}
}
fn format_compact_summary_text(summary: &str) -> String {
let mut text = summary.to_string();
while let (Some(start), Some(end)) = (
text.find("<analysis>"),
text.rfind("</analysis>"),
) {
text = format!("{}{}", &text[..start], &text[end + 10..]);
}
text = text.replace("<summary>", "Summary:\n").replace("</summary>", "");
text.trim().to_string()
}
pub async fn try_session_memory_compaction(
messages: &[Message],
_agent_id: Option<&str>,
auto_compact_threshold: Option<usize>,
) -> Option<SessionMemoryCompactResult> {
if !should_use_session_memory_compaction() {
return None;
}
crate::session_memory::wait_for_session_memory_extraction().await;
let session_memory = match crate::session_memory::get_session_memory_content().await {
Ok(Some(content)) => content,
_ => return None,
};
if is_session_memory_empty(&session_memory) {
return None;
}
let last_summarized_index =
crate::session_memory::get_last_summarized_message_id_as_index(messages)
.unwrap_or(messages.len().saturating_sub(1));
let start_index = calculate_messages_to_keep_index(messages, last_summarized_index.min(messages.len().saturating_sub(1)));
let messages_to_keep: Vec<Message> = messages[start_index..]
.iter()
.filter(|m| !is_compact_boundary_message(m))
.cloned()
.collect();
let pre_compact_token_count = estimate_message_tokens(messages);
let (session_memory, _was_truncated) = truncate_session_memory_for_compact(&session_memory);
let formatted_summary = format_compact_summary_text(&session_memory);
let boundary_content = format!(
"[Previous conversation summarized]\n\n{}",
get_compact_user_summary_message(&formatted_summary, Some(true), None, Some(true))
);
let boundary_msg = Message {
role: MessageRole::System,
content: boundary_content,
is_meta: Some(true),
uuid: None,
..Default::default()
};
let post_compact_token_count = estimate_message_tokens(
&[boundary_msg]
.iter()
.chain(messages_to_keep.iter())
.cloned()
.collect::<Vec<_>>()
.as_slice(),
);
if let Some(threshold) = auto_compact_threshold {
if post_compact_token_count >= threshold {
return None;
}
}
Some(SessionMemoryCompactResult {
compacted: true,
messages_to_keep,
session_memory_content: session_memory,
pre_compact_token_count,
post_compact_token_count,
})
}
#[derive(Debug, Clone)]
pub struct SessionMemoryCompactResult {
pub compacted: bool,
pub messages_to_keep: Vec<Message>,
pub session_memory_content: String,
pub pre_compact_token_count: usize,
pub post_compact_token_count: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = get_session_memory_compact_config();
assert_eq!(config.min_tokens, 10_000);
assert_eq!(config.min_text_block_messages, 5);
assert_eq!(config.max_tokens, 40_000);
}
#[test]
fn test_has_text_blocks() {
let msg = Message {
role: MessageRole::User,
content: "Hello".to_string(),
..Default::default()
};
assert!(has_text_blocks(&msg));
let empty = Message {
role: MessageRole::User,
content: String::new(),
..Default::default()
};
assert!(!has_text_blocks(&empty));
}
#[test]
fn test_adjust_index_empty_messages() {
assert_eq!(adjust_index_to_preserve_api_invariants(&[], 0), 0);
}
#[test]
fn test_calculate_messages_to_keep_empty() {
assert_eq!(calculate_messages_to_keep_index(&[], 0), 0);
}
#[test]
fn test_is_compact_boundary_message() {
let boundary = Message {
role: MessageRole::System,
content: "[Previous conversation summarized]".to_string(),
..Default::default()
};
assert!(is_compact_boundary_message(&boundary));
let normal = Message {
role: MessageRole::User,
content: "Hello".to_string(),
..Default::default()
};
assert!(!is_compact_boundary_message(&normal));
}
}