Skip to main content

hematite/agent/
compaction.rs

1use crate::agent::inference::ChatMessage;
2use std::collections::{BTreeSet, HashSet};
3use std::fmt::Write as _;
4
5/// Professional Compaction Configuration.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct CompactionConfig {
8    pub preserve_recent_messages: usize,
9    /// Token threshold before compaction fires. Set dynamically via `adaptive()`.
10    pub max_estimated_tokens: usize,
11}
12
13impl Default for CompactionConfig {
14    fn default() -> Self {
15        Self {
16            preserve_recent_messages: 10,
17            max_estimated_tokens: 15_000,
18        }
19    }
20}
21
22impl CompactionConfig {
23    /// Build a hardware-aware config that scales with the model's context window
24    /// and current VRAM pressure.
25    ///
26    /// - `context_length`: tokens the loaded model can handle (from `/api/v0/models`)
27    /// - `vram_ratio`: current VRAM usage 0.0–1.0 (from GpuState::ratio)
28    ///
29    /// Formula: threshold = ctx * 0.40 * (1 - vram * 0.5), clamped [4k, 60k].
30    /// preserve_recent_messages scales with context: roughly 1 message per 3k tokens.
31    pub fn adaptive(context_length: usize, vram_ratio: f64) -> Self {
32        let vram = vram_ratio.clamp(0.0, 1.0);
33        let effective = (context_length as f64 * 0.40 * (1.0 - vram * 0.5)) as usize;
34        let max_estimated_tokens = effective.clamp(4_000, 60_000);
35        let preserve_recent_messages = (context_length / 3_000).clamp(8, 20);
36        Self {
37            preserve_recent_messages,
38            max_estimated_tokens,
39        }
40    }
41}
42
43pub struct CompactionResult {
44    pub messages: Vec<ChatMessage>,
45    pub summary: Option<String>,
46}
47
48const DEFAULT_MAX_SUMMARY_CHARS: usize = 2_000;
49const DEFAULT_MAX_SUMMARY_LINES: usize = 40;
50const DEFAULT_MAX_SUMMARY_LINE_CHARS: usize = 200;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct SummaryCompressionBudget {
54    pub max_chars: usize,
55    pub max_lines: usize,
56    pub max_line_chars: usize,
57}
58
59impl Default for SummaryCompressionBudget {
60    fn default() -> Self {
61        Self {
62            max_chars: DEFAULT_MAX_SUMMARY_CHARS,
63            max_lines: DEFAULT_MAX_SUMMARY_LINES,
64            max_line_chars: DEFAULT_MAX_SUMMARY_LINE_CHARS,
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct SummaryCompressionResult {
71    pub summary: String,
72    pub original_chars: usize,
73    pub compressed_chars: usize,
74    pub original_lines: usize,
75    pub compressed_lines: usize,
76    pub removed_duplicate_lines: usize,
77    pub omitted_lines: usize,
78    pub truncated: bool,
79}
80
81pub fn compress_summary(
82    summary: &str,
83    budget: SummaryCompressionBudget,
84) -> SummaryCompressionResult {
85    let original_chars = summary.chars().count();
86    let original_lines = summary.lines().count();
87    let normalized = normalize_summary_lines(summary, budget.max_line_chars);
88
89    if normalized.lines.is_empty() || budget.max_chars == 0 || budget.max_lines == 0 {
90        return SummaryCompressionResult {
91            summary: String::new(),
92            original_chars,
93            compressed_chars: 0,
94            original_lines,
95            compressed_lines: 0,
96            removed_duplicate_lines: normalized.removed_duplicate_lines,
97            omitted_lines: normalized.lines.len(),
98            truncated: original_chars > 0,
99        };
100    }
101
102    let selected = select_summary_line_indexes(&normalized.lines, budget);
103    let mut compressed_lines = {
104        let mut v = Vec::with_capacity(selected.len());
105        for index in &selected {
106            v.push(normalized.lines[*index].clone());
107        }
108        v
109    };
110    if compressed_lines.is_empty() {
111        compressed_lines.push(truncate_summary_line(
112            &normalized.lines[0],
113            budget.max_chars,
114        ));
115    }
116    let omitted_lines = normalized
117        .lines
118        .len()
119        .saturating_sub(compressed_lines.len());
120    if omitted_lines > 0 {
121        push_summary_line_with_budget(
122            &mut compressed_lines,
123            format!("- ... {omitted_lines} additional line(s) omitted."),
124            budget,
125        );
126    }
127
128    let compressed_summary = compressed_lines.join("\n");
129    SummaryCompressionResult {
130        summary: compressed_summary.clone(),
131        original_chars,
132        compressed_chars: compressed_summary.chars().count(),
133        original_lines,
134        compressed_lines: compressed_lines.len(),
135        removed_duplicate_lines: normalized.removed_duplicate_lines,
136        omitted_lines,
137        truncated: compressed_summary != summary.trim(),
138    }
139}
140
141pub fn compress_summary_text(summary: &str) -> String {
142    compress_summary(summary, SummaryCompressionBudget::default()).summary
143}
144
145const COMPACT_PREAMBLE: &str = "## CONTEXT SUMMARY (RECURSIVE CHAIN)\n\
146    This session is being continued from a previous conversation. The summary below covers the earlier portion.\n\n";
147const COMPACT_INSTRUCTION: &str = "\n\nIMPORTANT: Resume directly from the last message. Do not recap or acknowledge this summary.";
148
149/// Layer 6: Structured Session Memory.
150/// Preserves the "Mission Context" across compactions.
151#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
152pub struct SessionCheckpoint {
153    pub state: String,
154    pub summary: String,
155}
156
157#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
158pub struct SessionVerification {
159    pub successful: bool,
160    pub summary: String,
161}
162
163#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
164pub struct SessionCompactionLedger {
165    pub count: u32,
166    pub removed_message_count: usize,
167    pub summary: String,
168}
169
170#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
171pub struct SessionMemory {
172    pub current_task: String,
173    pub working_set: std::collections::HashSet<String>,
174    pub learnings: Vec<String>,
175    #[serde(default)]
176    pub current_plan: Option<crate::tools::plan::PlanHandoff>,
177    #[serde(default)]
178    pub last_checkpoint: Option<SessionCheckpoint>,
179    #[serde(default)]
180    pub last_blocker: Option<SessionCheckpoint>,
181    #[serde(default)]
182    pub last_recovery: Option<SessionCheckpoint>,
183    #[serde(default)]
184    pub last_verification: Option<SessionVerification>,
185    #[serde(default)]
186    pub last_compaction: Option<SessionCompactionLedger>,
187}
188
189impl SessionMemory {
190    pub fn has_signal(&self) -> bool {
191        let task = self.current_task.trim();
192        (!task.is_empty() && task != "Ready for new mission.")
193            || !self.working_set.is_empty()
194            || !self.learnings.is_empty()
195            || self.last_checkpoint.is_some()
196            || self.last_blocker.is_some()
197            || self.last_recovery.is_some()
198            || self.last_verification.is_some()
199            || self.last_compaction.is_some()
200            || self
201                .current_plan
202                .as_ref()
203                .map(|plan| plan.has_signal())
204                .unwrap_or(false)
205    }
206
207    pub fn to_prompt(&self) -> String {
208        let mut s = format!("- **Active Task**: {}\n", self.current_task);
209        if let Some(plan) = &self.current_plan {
210            if plan.has_signal() {
211                s.push_str("- **Active Plan Handoff**:\n");
212                s.push_str(&plan.to_prompt());
213            }
214        }
215        if !self.working_set.is_empty() {
216            s.push_str("- **Working Set**: ");
217            let mut first = true;
218            for f in &self.working_set {
219                if !first {
220                    s.push_str(", ");
221                }
222                s.push_str(f);
223                first = false;
224            }
225            s.push('\n');
226        }
227        if !self.learnings.is_empty() {
228            s.push_str("- **Key Learnings**:\n");
229            for l in &self.learnings {
230                let _ = writeln!(s, "  - {l}");
231            }
232        }
233        if let Some(checkpoint) = &self.last_checkpoint {
234            if checkpoint.summary.trim().is_empty() {
235                let _ = writeln!(s, "- **Latest Checkpoint**: {}", checkpoint.state);
236            } else {
237                let _ = writeln!(
238                    s,
239                    "- **Latest Checkpoint**: {} - {}",
240                    checkpoint.state, checkpoint.summary
241                );
242            }
243        }
244        if let Some(blocker) = &self.last_blocker {
245            if blocker.summary.trim().is_empty() {
246                let _ = writeln!(s, "- **Latest Blocker**: {}", blocker.state);
247            } else {
248                let _ = writeln!(
249                    s,
250                    "- **Latest Blocker**: {} - {}",
251                    blocker.state, blocker.summary
252                );
253            }
254        }
255        if let Some(recovery) = &self.last_recovery {
256            if recovery.summary.trim().is_empty() {
257                let _ = writeln!(s, "- **Latest Recovery**: {}", recovery.state);
258            } else {
259                let _ = writeln!(
260                    s,
261                    "- **Latest Recovery**: {} - {}",
262                    recovery.state, recovery.summary
263                );
264            }
265        }
266        if let Some(verification) = &self.last_verification {
267            let status = if verification.successful {
268                "passed"
269            } else {
270                "failed"
271            };
272            let _ = writeln!(
273                s,
274                "- **Latest Verification**: {} - {}",
275                status, verification.summary
276            );
277        }
278        if let Some(compaction) = &self.last_compaction {
279            let _ = writeln!(
280                s,
281                "- **Latest Compaction**: pass {} removed {} message(s) - {}",
282                compaction.count, compaction.removed_message_count, compaction.summary
283            );
284        }
285        s
286    }
287
288    pub fn inherit_runtime_ledger_from(&mut self, other: &Self) {
289        self.last_checkpoint = other.last_checkpoint.clone();
290        self.last_blocker = other.last_blocker.clone();
291        self.last_recovery = other.last_recovery.clone();
292        self.last_verification = other.last_verification.clone();
293        self.last_compaction = other.last_compaction.clone();
294    }
295
296    pub fn record_checkpoint(&mut self, state: impl Into<String>, summary: impl Into<String>) {
297        let checkpoint = SessionCheckpoint {
298            state: state.into(),
299            summary: summary.into(),
300        };
301        let state_name = checkpoint.state.as_str();
302        if state_name == "recovering_provider" {
303            self.last_recovery = Some(checkpoint.clone());
304        }
305        if state_name.starts_with("blocked_") {
306            self.last_blocker = Some(checkpoint.clone());
307        }
308        self.last_checkpoint = Some(checkpoint);
309    }
310
311    pub fn record_verification(&mut self, successful: bool, summary: impl Into<String>) {
312        self.last_verification = Some(SessionVerification {
313            successful,
314            summary: summary.into(),
315        });
316    }
317
318    pub fn record_recovery(&mut self, state: impl Into<String>, summary: impl Into<String>) {
319        let checkpoint = SessionCheckpoint {
320            state: state.into(),
321            summary: summary.into(),
322        };
323        self.last_recovery = Some(checkpoint.clone());
324        self.last_checkpoint = Some(checkpoint);
325    }
326
327    pub fn record_compaction(&mut self, removed_message_count: usize, summary: impl Into<String>) {
328        let count = self
329            .last_compaction
330            .as_ref()
331            .map_or(1, |entry| entry.count.saturating_add(1));
332        self.last_compaction = Some(SessionCompactionLedger {
333            count,
334            removed_message_count,
335            summary: summary.into(),
336        });
337    }
338
339    pub fn clear(&mut self) {
340        self.current_task = "Ready for new mission.".to_string();
341        self.working_set.clear();
342        self.learnings.clear();
343        self.current_plan = None;
344        self.last_checkpoint = None;
345        self.last_blocker = None;
346        self.last_recovery = None;
347        self.last_verification = None;
348        self.last_compaction = None;
349    }
350}
351
352/// Returns true when history is large enough to warrant compaction.
353/// Pass the model's context_length and current vram_ratio for adaptive thresholds.
354pub fn should_compact(history: &[ChatMessage], context_length: usize, vram_ratio: f64) -> bool {
355    let config = CompactionConfig::adaptive(context_length, vram_ratio);
356    history.len().saturating_sub(1) > config.preserve_recent_messages + 5
357        || estimate_compactable_tokens(history) > config.max_estimated_tokens
358}
359
360pub fn compact_history(
361    history: &[ChatMessage],
362    existing_summary: Option<&str>,
363    config: CompactionConfig,
364    // The index of the user message that started the CURRENT turn.
365    // We must NEVER summarize past this index if we are in the middle of a turn.
366    anchor_index: Option<usize>,
367) -> CompactionResult {
368    if history.len() <= config.preserve_recent_messages + 5 {
369        return CompactionResult {
370            messages: history.to_vec(),
371            summary: existing_summary.map(|s| s.to_string()),
372        };
373    }
374
375    // Triple-Slicer Strategy:
376    // 1. [SYSTEM] (Index 0)
377    // 2. [PAST TURNS] (Index 1 .. Anchor) -> Folded into summary.
378    // 3. [ENTRY PROMPT] (Index Anchor) -> Kept verbatim for Jinja alignment.
379    // 4. [MIDDLE OF TURN] (Index Anchor+1 .. End - Preserve) -> Folded into summary.
380    // 5. [RECENT WORK] (End - Preserve .. End) -> Kept verbatim.
381
382    // The anchor MUST be at least 1 (to avoid 1..0 slice panics) and
383    // capped at history.len() - 1.
384    let anchor = anchor_index.unwrap_or(1).max(1).min(history.len() - 1);
385    let keep_from = history
386        .len()
387        .saturating_sub(config.preserve_recent_messages);
388
389    let mut messages_to_summarize = Vec::with_capacity(history.len());
390    let mut preserved_messages = Vec::with_capacity(history.len());
391
392    // Preserve the Turn Entry User Prompt as the primary anchor.
393    // Everything before it is permanently summarized.
394    if anchor > 1 {
395        messages_to_summarize.extend(history[1..anchor].iter().cloned());
396    }
397    preserved_messages.push(history[anchor].clone());
398
399    // Evaluate the Middle of the Turn.
400    if keep_from > anchor + 1 {
401        // We have enough bulk in the current turn to justify a "Partial Turn" summary.
402        messages_to_summarize.extend(history[anchor + 1..keep_from].iter().cloned());
403        preserved_messages.extend(history[keep_from..].iter().cloned());
404    } else {
405        // Not enough bulk inside the turn yet; just preserve the rest.
406        preserved_messages.extend(history[anchor + 1..].iter().cloned());
407    }
408
409    let new_summary_txt = build_technical_summary(&messages_to_summarize);
410    let merged_summary = match existing_summary {
411        Some(existing) => merge_summaries(existing, &new_summary_txt),
412        None => new_summary_txt,
413    };
414
415    let summary_content = format!(
416        "{}{}{}",
417        COMPACT_PREAMBLE, merged_summary, COMPACT_INSTRUCTION
418    );
419    let summary_msg = ChatMessage::system(&summary_content);
420
421    let mut new_history = vec![history[0].clone()];
422    new_history.push(summary_msg);
423    new_history.extend(preserved_messages);
424
425    CompactionResult {
426        messages: new_history,
427        summary: Some(merged_summary),
428    }
429}
430
431/// Heuristic extraction of "The Mission" from a set of messages.
432pub fn extract_memory(messages: &[ChatMessage]) -> SessionMemory {
433    let mut mem = SessionMemory::default();
434
435    // Use the most recent user message for current_task to avoid topic pollution,
436    // but scan ALL turns for working_set so files touched earlier in the session
437    // are not silently dropped after the first compaction pass.
438    let last_user_idx = messages.iter().rposition(|m| m.role == "user");
439
440    if let Some(idx) = last_user_idx {
441        let m = &messages[idx];
442        let content_str = m.content.as_str();
443        let limit = 250;
444        mem.current_task = content_str.chars().take(limit).collect();
445        if content_str.len() > limit {
446            mem.current_task.push_str("...");
447        }
448    }
449
450    // Working set: collect path args from every tool call across all turns,
451    // giving higher priority to tool calls in the most recent user turn.
452    // Cap at 12 files; most-recently-touched files survive longest.
453    let mut all_files: Vec<String> = Vec::with_capacity(messages.len());
454    for msg in messages {
455        if let Some(calls) = &msg.tool_calls {
456            for call in calls {
457                let args = call.function.arguments.clone();
458                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
459                    // Push in traversal order so later (more recent) entries
460                    // win deduplication when we reverse below.
461                    all_files.push(path.to_string());
462                }
463            }
464        }
465    }
466    // Keep unique files, most-recent first, capped at 12.
467    let mut seen = HashSet::new();
468    for path in all_files.into_iter().rev() {
469        if seen.insert(path.clone()) {
470            mem.working_set.insert(path);
471            if mem.working_set.len() >= 12 {
472                break;
473            }
474        }
475    }
476
477    // Learnings: scan the most recent user turn only to avoid stale signals.
478    if let Some(idx) = last_user_idx {
479        for turn_msg in &messages[idx..] {
480            if turn_msg.role == "tool" {
481                let content_str = turn_msg.content.as_str();
482                if content_str.contains("Error:")
483                    || content_str.contains("Finished")
484                    || content_str.contains("Complete")
485                {
486                    let lines: Vec<_> = content_str.lines().take(2).collect();
487                    mem.learnings.push(lines.join(" "));
488                }
489            }
490        }
491    }
492
493    // De-duplicate and cap learnings.
494    mem.learnings.dedup();
495    if mem.learnings.len() > 5 {
496        mem.learnings.truncate(5);
497    }
498
499    mem
500}
501
502pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
503    messages
504        .iter()
505        .map(|m| m.content.as_str().len() / 4 + 1)
506        .sum()
507}
508
509pub fn estimate_compactable_tokens(history: &[ChatMessage]) -> usize {
510    if history.len() <= 1 {
511        0
512    } else {
513        estimate_tokens(&history[1..])
514    }
515}
516
517fn build_technical_summary(messages: &[ChatMessage]) -> String {
518    let mut lines = vec![format!(
519        "- Scope: {} earlier turns compacted.",
520        messages.len()
521    )];
522
523    // 1. Extract files from tool-call path args (precise) then fall back to
524    //    word-scan for any path-like tokens not captured that way.
525    let mut files: IndexedSet = IndexedSet::default();
526    let mut tools: HashSet<String> = HashSet::new();
527    let mut requests: Vec<String> = Vec::with_capacity(messages.len().min(16));
528    let mut assistant_notes: Vec<String> = Vec::with_capacity(messages.len().min(8));
529    // Tool results: verify_build, edit outcomes, notable errors.
530    let mut verify_outcome: Option<bool> = None;
531    let mut error_snippets: Vec<String> = Vec::with_capacity(4);
532
533    for m in messages {
534        // Precise file extraction from tool call arguments.
535        if let Some(calls) = &m.tool_calls {
536            for call in calls {
537                tools.insert(call.function.name.clone());
538                let args = call.function.arguments.clone();
539                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
540                    files.insert(path.to_string());
541                }
542            }
543        }
544
545        // Tool result signals.
546        if m.role == "tool" {
547            let text = m.content.as_str();
548            // verify_build result — last one wins.
549            if text.contains("BUILD OK") || text.contains("BUILD SUCCESS") {
550                verify_outcome = Some(true);
551            } else if text.contains("BUILD FAIL") || text.contains("error[") {
552                verify_outcome = Some(false);
553            }
554            // Capture first error line from any tool result.
555            if text.contains("Error:") || text.contains("error:") {
556                if let Some(err_line) = text.lines().find(|l| {
557                    l.trim_start().starts_with("Error:") || l.trim_start().starts_with("error:")
558                }) {
559                    let snippet: String = err_line.chars().take(100).collect();
560                    error_snippets.push(snippet);
561                }
562            }
563        }
564
565        // User requests (up to 4, most recent last).
566        if m.role == "user" && !m.content.as_str().trim().is_empty() && requests.len() < 4 {
567            let text = m
568                .content
569                .as_str()
570                .trim_start_matches("/think\n")
571                .trim_start_matches("/no_think\n")
572                .trim();
573            requests.push(truncate_summary_line(
574                &collapse_inline_whitespace(text),
575                140,
576            ));
577        }
578
579        // Assistant prose (up to 3) — capture decisions and explanations made.
580        if m.role == "assistant"
581            && !m.content.as_str().trim().is_empty()
582            && m.tool_calls.as_ref().is_none_or(|tc| tc.is_empty())
583            && assistant_notes.len() < 3
584        {
585            let text = m.content.as_str().trim();
586            if text.len() > 20 {
587                assistant_notes.push(truncate_summary_line(
588                    &collapse_inline_whitespace(text),
589                    120,
590                ));
591            }
592        }
593
594        // Word-scan fallback for path-like tokens not in tool args.
595        for word in m.content.as_str().split_whitespace() {
596            let clean = word.trim_matches(|c: char| {
597                matches!(c, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
598            });
599            if clean.len() > 4
600                && clean.contains('.')
601                && (clean.contains('/') || clean.contains('\\'))
602            {
603                files.insert(clean.to_string());
604            }
605        }
606    }
607
608    if !files.0.is_empty() {
609        let list: Vec<String> = files.0.into_iter().take(10).collect();
610        lines.push(format!("- Key files: {}.", list.join(", ")));
611    }
612    if !tools.is_empty() {
613        let list: Vec<String> = tools.into_iter().take(8).collect();
614        lines.push(format!("- Tools used: {}.", list.join(", ")));
615    }
616    if let Some(ok) = verify_outcome {
617        lines.push(format!(
618            "- Last verify_build: {}.",
619            if ok { "BUILD OK" } else { "BUILD FAILED" }
620        ));
621    }
622    // Include up to 2 unique error snippets.
623    error_snippets.dedup();
624    for snippet in error_snippets.into_iter().take(2) {
625        lines.push(format!("- Error seen: {}", snippet));
626    }
627    if !assistant_notes.is_empty() {
628        lines.push("- Assistant decisions/responses (oldest→newest):".to_string());
629        for note in &assistant_notes {
630            lines.push(format!("  - {}", note));
631        }
632    }
633    if !requests.is_empty() {
634        lines.push("- User requests (oldest→newest):".to_string());
635        for request in &requests {
636            lines.push(format!("  - {}", request));
637        }
638    }
639
640    // 2. Timeline: last 6 messages for context.
641    lines.push("- Compacted context:".to_string());
642    for m in messages.iter().rev().take(6).rev() {
643        let content_str = m.content.as_str();
644        let preview = if content_str.len() > 120 {
645            let mut s: String = content_str.chars().take(117).collect();
646            s.push_str("...");
647            s
648        } else if content_str.is_empty()
649            && m.tool_calls
650                .as_ref()
651                .map(|c| !c.is_empty())
652                .unwrap_or(false)
653        {
654            {
655                let calls = m.tool_calls.as_ref().unwrap();
656                let mut s = String::with_capacity(
657                    14 + calls.iter().map(|c| c.function.name.len()).sum::<usize>()
658                        + calls.len().saturating_sub(1) * 2,
659                );
660                s.push_str("Executing: ");
661                for (i, c) in calls.iter().enumerate() {
662                    if i > 0 {
663                        s.push_str(", ");
664                    }
665                    s.push_str(&c.function.name);
666                }
667                s
668            }
669        } else {
670            content_str.to_string()
671        };
672        lines.push(format!(
673            "  - {}: {}",
674            m.role,
675            preview.replace('\n', " ").trim()
676        ));
677    }
678
679    compress_summary_text(&lines.join("\n"))
680}
681
682/// Insertion-ordered set: tracks insertion order for deterministic file output
683/// while deduplicating entries.
684#[derive(Default)]
685struct IndexedSet(Vec<String>);
686
687impl IndexedSet {
688    fn insert(&mut self, s: String) {
689        if !self.0.contains(&s) {
690            self.0.push(s);
691        }
692    }
693}
694
695fn merge_summaries(existing: &str, new: &str) -> String {
696    compress_summary_text(&format!(
697        "Conversation summary:\n- Previously compacted context:\n{}\n- Newly compacted context:\n{}",
698        existing.trim(),
699        new.trim()
700    ))
701}
702
703#[derive(Debug, Default)]
704struct NormalizedSummary {
705    lines: Vec<String>,
706    removed_duplicate_lines: usize,
707}
708
709fn normalize_summary_lines(summary: &str, max_line_chars: usize) -> NormalizedSummary {
710    let mut seen = BTreeSet::new();
711    let mut lines = Vec::new();
712    let mut removed_duplicate_lines = 0;
713
714    for raw_line in summary.lines() {
715        let normalized = collapse_inline_whitespace(raw_line);
716        if normalized.is_empty() {
717            continue;
718        }
719        let truncated = truncate_summary_line(&normalized, max_line_chars);
720        let dedupe_key = truncated.to_ascii_lowercase();
721        if !seen.insert(dedupe_key) {
722            removed_duplicate_lines += 1;
723            continue;
724        }
725        lines.push(truncated);
726    }
727
728    NormalizedSummary {
729        lines,
730        removed_duplicate_lines,
731    }
732}
733
734fn select_summary_line_indexes(lines: &[String], budget: SummaryCompressionBudget) -> Vec<usize> {
735    let mut selected = BTreeSet::<usize>::new();
736
737    for priority in 0..=3 {
738        for (index, line) in lines.iter().enumerate() {
739            if selected.contains(&index) || summary_line_priority(line) != priority {
740                continue;
741            }
742            let new_len = selected.len() + 1;
743            if new_len > budget.max_lines {
744                continue;
745            }
746            let char_count: usize = selected
747                .iter()
748                .map(|si| lines[*si].chars().count())
749                .sum::<usize>()
750                + line.chars().count()
751                + new_len.saturating_sub(1);
752            if char_count > budget.max_chars {
753                continue;
754            }
755            selected.insert(index);
756        }
757    }
758
759    selected.into_iter().collect()
760}
761
762fn push_summary_line_with_budget(
763    lines: &mut Vec<String>,
764    line: String,
765    budget: SummaryCompressionBudget,
766) {
767    let new_len = lines.len() + 1;
768    if new_len > budget.max_lines {
769        return;
770    }
771    let char_count: usize = lines.iter().map(|l| l.chars().count()).sum::<usize>()
772        + line.chars().count()
773        + new_len.saturating_sub(1);
774    if char_count <= budget.max_chars {
775        lines.push(line);
776    }
777}
778
779fn summary_line_priority(line: &str) -> usize {
780    if line == "Conversation summary:" || is_core_summary_detail(line) {
781        0
782    } else if line.ends_with(':') {
783        1
784    } else if line.starts_with("- ") || line.starts_with("  - ") {
785        2
786    } else {
787        3
788    }
789}
790
791fn is_core_summary_detail(line: &str) -> bool {
792    [
793        "- Scope:",
794        "- Key files referenced:",
795        "- Tools mentioned:",
796        "- Recent user requests:",
797        "- Previously compacted context:",
798        "- Newly compacted context:",
799    ]
800    .iter()
801    .any(|prefix| line.starts_with(prefix))
802}
803
804fn collapse_inline_whitespace(line: &str) -> String {
805    let mut out = String::with_capacity(line.len());
806    for word in line.split_whitespace() {
807        if !out.is_empty() {
808            out.push(' ');
809        }
810        out.push_str(word);
811    }
812    out
813}
814
815fn truncate_summary_line(line: &str, max_chars: usize) -> String {
816    if max_chars == 0 {
817        return String::new();
818    }
819    if max_chars == 1 {
820        return ".".to_string();
821    }
822    let take_n = max_chars.saturating_sub(3);
823    let mut chars = line.chars();
824    let mut head = String::with_capacity(take_n);
825    head.extend(chars.by_ref().take(take_n));
826    // If 4+ chars remain, total > max_chars — truncate. Otherwise return original.
827    if chars.take(4).count() >= 4 {
828        head.push_str("...");
829        head
830    } else {
831        line.to_string()
832    }
833}