use crate::context::estimate_tokens;
#[derive(Debug, Clone)]
pub struct ContextEntry {
pub source: String,
pub content: String,
pub importance: f32,
pub age_seconds: u64,
pub relevance: f32,
}
#[derive(Debug, Clone)]
pub struct CompactedContext {
pub text: String,
pub entries_retained: usize,
pub entries_dropped: usize,
pub tokens: usize,
}
#[derive(Debug, Clone)]
pub struct CompactionConfig {
pub max_tokens: usize,
pub max_entry_chars: usize,
pub dedup_threshold: f64,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
max_tokens: 2000,
max_entry_chars: 200,
dedup_threshold: 0.8,
}
}
}
pub fn compact(entries: &[ContextEntry], config: &CompactionConfig) -> CompactedContext {
if entries.is_empty() {
return CompactedContext {
text: String::new(),
entries_retained: 0,
entries_dropped: 0,
tokens: 0,
};
}
let mut compressed: Vec<(f64, String, &str)> = entries
.iter()
.map(|e| {
let text = compress_entry(&e.content, config.max_entry_chars);
let priority = compute_priority(e);
(priority, text, e.source.as_str())
})
.collect();
deduplicate(&mut compressed, config.dedup_threshold);
compressed.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let mut output = String::new();
let mut retained = 0usize;
let mut tokens_used = 0usize;
let mut by_source: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
let mut budget_entries = Vec::new();
for (_, text, source) in &compressed {
let entry_tokens = estimate_tokens(text);
if tokens_used + entry_tokens > config.max_tokens {
break;
}
tokens_used += entry_tokens;
retained += 1;
budget_entries.push((*source, text.as_str()));
}
for (source, text) in &budget_entries {
by_source.entry(source).or_default().push(text);
}
for (source, texts) in &by_source {
let header = source_header(source);
output.push_str(&header);
output.push('\n');
for text in texts {
output.push_str("- ");
output.push_str(text);
output.push('\n');
}
output.push('\n');
}
let final_tokens = estimate_tokens(&output);
CompactedContext {
text: output.trim_end().to_string(),
entries_retained: retained,
entries_dropped: entries.len().saturating_sub(retained),
tokens: final_tokens,
}
}
fn compress_entry(content: &str, max_chars: usize) -> String {
let mut text = content.trim().to_string();
text = text.replace("**", "");
text = text.replace("## ", "");
text = text.replace("### ", "");
if text.contains('\n') {
text = text
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("; ");
}
if text.starts_with("- ") {
text = text[2..].to_string();
}
if text.starts_with("• ") {
text = text["• ".len()..].to_string();
}
while let Some(start) = text.find('[') {
if let Some(end) = text[start..].find(']') {
let bracket_content = &text[start + 1..start + end];
if bracket_content.contains('|')
|| bracket_content.contains("sim=")
|| bracket_content.contains("_memory")
{
text = format!(
"{}{}",
text[..start].trim_end(),
text[start + end + 1..].trim_start()
);
continue;
}
}
break;
}
if text.len() > max_chars {
text = text.chars().take(max_chars).collect();
if let Some(last_space) = text.rfind(' ')
&& last_space > max_chars / 2
{
text.truncate(last_space);
}
text.push_str("...");
}
text
}
fn compute_priority(entry: &ContextEntry) -> f64 {
let importance_norm = entry.importance as f64 / 10.0; let relevance = entry.relevance as f64;
let recency = if entry.age_seconds == 0 {
1.0
} else {
(-0.693 * entry.age_seconds as f64 / 3600.0).exp() };
if relevance > 0.1 {
0.4 * relevance + 0.3 * importance_norm + 0.3 * recency
} else {
0.2 * importance_norm + 0.8 * recency
}
}
fn deduplicate(entries: &mut Vec<(f64, String, &str)>, threshold: f64) {
let mut i = 0;
while i < entries.len() {
let mut duplicate = false;
for j in 0..i {
if text_overlap(&entries[j].1, &entries[i].1) > threshold {
duplicate = true;
break;
}
}
if duplicate {
entries.remove(i);
} else {
i += 1;
}
}
}
pub fn text_overlap_score(a: &str, b: &str) -> f64 {
text_overlap(a, b)
}
fn text_overlap(a: &str, b: &str) -> f64 {
let trigrams_a = word_trigrams(a);
let trigrams_b = word_trigrams(b);
if trigrams_a.is_empty() && trigrams_b.is_empty() {
return 1.0;
}
let intersection = trigrams_a.intersection(&trigrams_b).count();
let union = trigrams_a.union(&trigrams_b).count();
if union == 0 {
0.0
} else {
intersection as f64 / union as f64
}
}
fn word_trigrams(text: &str) -> std::collections::HashSet<String> {
let words: Vec<&str> = text.split_whitespace().collect();
if words.len() < 3 {
return words.iter().map(|w| w.to_ascii_lowercase()).collect();
}
words
.windows(3)
.map(|w| format!("{} {} {}", w[0], w[1], w[2]).to_ascii_lowercase())
.collect()
}
pub fn compact_text(text: &str, max_tokens: usize) -> String {
if text.is_empty() || max_tokens == 0 {
return String::new();
}
let current_tokens = crate::context::estimate_tokens(text);
if current_tokens <= max_tokens {
return text.to_string(); }
let mut output = String::new();
let mut used = 0usize;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let header_tokens = crate::context::estimate_tokens(trimmed);
if used + header_tokens > max_tokens {
break;
}
if !output.is_empty() {
output.push('\n');
}
output.push_str(trimmed);
output.push('\n');
used += header_tokens;
continue;
}
let compressed = compress_entry(trimmed, 150);
let line_tokens = crate::context::estimate_tokens(&compressed);
if used + line_tokens > max_tokens {
break;
}
output.push_str(&compressed);
output.push('\n');
used += line_tokens;
}
output.trim_end().to_string()
}
fn source_header(source: &str) -> String {
match source {
"working" => "[Working Memory]".to_string(),
"ambient" => "[Recent Activity]".to_string(),
"episodic" | "semantic" => "[Relevant Memories]".to_string(),
"procedural" => "[Skills]".to_string(),
"relationship" => "[Relationships]".to_string(),
"checkpoint" => "[Session Context]".to_string(),
"hippocampus" => "[Storage]".to_string(),
"topic_summary" => "[Earlier Topics]".to_string(),
other => format!("[{other}]"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(
source: &str,
content: &str,
importance: f32,
age_secs: u64,
relevance: f32,
) -> ContextEntry {
ContextEntry {
source: source.to_string(),
content: content.to_string(),
importance,
age_seconds: age_secs,
relevance,
}
}
#[test]
fn empty_input_produces_empty_output() {
let result = compact(&[], &CompactionConfig::default());
assert!(result.text.is_empty());
assert_eq!(result.entries_retained, 0);
}
#[test]
fn single_entry_passes_through() {
let entries = vec![entry(
"episodic",
"User asked about workspace cleanup",
5.0,
300,
0.8,
)];
let result = compact(&entries, &CompactionConfig::default());
assert!(result.text.contains("workspace cleanup"));
assert_eq!(result.entries_retained, 1);
assert_eq!(result.entries_dropped, 0);
}
#[test]
fn duplicates_are_removed() {
let entries = vec![
entry(
"episodic",
"Agent cleaned up workspace files",
5.0,
300,
0.8,
),
entry("ambient", "Agent cleaned up workspace files", 5.0, 300, 0.0),
];
let result = compact(&entries, &CompactionConfig::default());
assert_eq!(result.entries_retained, 1);
assert_eq!(result.entries_dropped, 1);
}
#[test]
fn budget_enforced() {
let entries: Vec<ContextEntry> = (0..100)
.map(|i| {
entry(
"episodic",
&format!("Memory entry number {i} with some content to take up space"),
5.0,
i * 60,
0.5,
)
})
.collect();
let config = CompactionConfig {
max_tokens: 100,
..Default::default()
};
let result = compact(&entries, &config);
assert!(result.tokens <= 110); assert!(result.entries_retained < 100);
assert!(result.entries_dropped > 0);
}
#[test]
fn high_priority_entries_retained_first() {
let entries = vec![
entry("episodic", "Old low-relevance memory", 1.0, 7200, 0.1),
entry("ambient", "Very recent high-importance fact", 9.0, 30, 0.0),
entry("semantic", "Highly relevant stored fact", 5.0, 3600, 0.9),
];
let config = CompactionConfig {
max_tokens: 15, ..Default::default()
};
let result = compact(&entries, &config);
assert!(result.entries_retained >= 1);
if result.entries_retained < 3 {
assert!(!result.text.contains("Old low-relevance"));
}
}
#[test]
fn compress_strips_formatting() {
let raw = "**Important**: This is a [episodic_memory | sim=0.85] formatted entry\nWith multiple lines\nAnd verbose content";
let compressed = compress_entry(raw, 200);
assert!(!compressed.contains("**"));
assert!(!compressed.contains('\n'));
assert!(!compressed.contains("sim=0.85"));
}
#[test]
fn compress_truncates_long_entries() {
let long = "a ".repeat(200);
let compressed = compress_entry(&long, 50);
assert!(compressed.len() < 60); assert!(compressed.ends_with("..."));
}
#[test]
fn priority_favors_recent_relevant_entries() {
let recent_relevant = ContextEntry {
source: "episodic".into(),
content: "test".into(),
importance: 5.0,
age_seconds: 60,
relevance: 0.9,
};
let old_irrelevant = ContextEntry {
source: "episodic".into(),
content: "test".into(),
importance: 5.0,
age_seconds: 86400,
relevance: 0.1,
};
assert!(compute_priority(&recent_relevant) > compute_priority(&old_irrelevant));
}
#[test]
fn text_overlap_identical() {
assert!((text_overlap("the quick brown fox", "the quick brown fox") - 1.0).abs() < 0.01);
}
#[test]
fn text_overlap_different() {
assert!(text_overlap("the quick brown fox", "completely different words here") < 0.1);
}
#[test]
fn grouped_output_has_section_headers() {
let entries = vec![
entry("ambient", "Recent thing happened", 5.0, 60, 0.0),
entry("procedural", "How to run a scan", 5.0, 3600, 0.7),
];
let result = compact(&entries, &CompactionConfig::default());
assert!(result.text.contains("[Recent Activity]"));
assert!(result.text.contains("[Skills]"));
}
}