Skip to main content

hematite/agent/
compaction.rs

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