goosedump 0.12.43

Browse, search, compact, and learn from coding-agent sessions
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

//! Shared text helpers for compaction extract and brief.

use std::borrow::Cow;
use std::time::Instant;

use anyhow::Result;

use crate::engine::message::{ConversationMessage, MessageKind, MessageView};
use crate::engine::model::Embedder;
use crate::engine::text;

use super::types::{
    CompactError, CompactProfiler, SECTION_CONSTRAINTS, SECTION_CRITICAL_CONTEXT, SECTION_DONE,
    SECTION_GOAL, SECTION_IN_PROGRESS, SECTION_PROGRESS, TAG_MODIFIED_FILES, TAG_READ_FILES,
};

pub(super) fn compact_summary_text(message: &ConversationMessage) -> Option<Cow<'_, str>> {
    let text = match &message.kind {
        MessageKind::PiCompaction { summary, .. }
        | MessageKind::PiBranchSummary { summary, .. } => Cow::Borrowed(summary.as_str()),
        _ => match message.view() {
            MessageView::Text { text, .. } | MessageView::Assistant { text, .. } => {
                Cow::Owned(text)
            }
            MessageView::ToolResult(_) | MessageView::Bash(_) => return None,
        },
    };
    is_compact_summary(&text).then_some(text)
}

pub(super) fn is_compaction_record(message: &ConversationMessage) -> bool {
    matches!(
        &message.kind,
        MessageKind::PiCompaction { .. } | MessageKind::PiBranchSummary { .. }
    )
}

pub(super) fn is_compact_summary(text: &str) -> bool {
    text.lines()
        .any(|line| matches_compact_section(line.trim()))
}

pub(super) fn matches_compact_section(line: &str) -> bool {
    matches!(
        line,
        SECTION_GOAL
            | SECTION_CONSTRAINTS
            | SECTION_PROGRESS
            | SECTION_DONE
            | SECTION_IN_PROGRESS
            | SECTION_CRITICAL_CONTEXT
            | TAG_READ_FILES
            | TAG_MODIFIED_FILES
    )
}

/// Strip a trailing ` (#entry-id)` reference so dedup compares content, not
/// volatile ids.
pub(super) fn strip_entry_ref(line: &str) -> &str {
    match line.rfind(" (#") {
        Some(index) if line.ends_with(')') => &line[..index],
        _ => line,
    }
}

pub(super) fn entry_ref(entry_id: &str) -> String {
    format!("#{entry_id}")
}

pub(super) fn non_empty_lines(value: &str) -> Vec<String> {
    text::sanitize(value)
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .filter(|line| !line.starts_with("<skill") && !line.starts_with("</skill"))
        .map(ToString::to_string)
        .collect()
}

pub(super) fn clean_sentence(value: &str, max_chars: usize) -> String {
    let flat = value.split_whitespace().collect::<Vec<_>>().join(" ");
    text::clip(flat.trim_matches(['-', '*', ' ']), max_chars)
}

pub(super) fn referenced_sentence(value: &str, max_chars: usize, entry_id: &str) -> String {
    format!(
        "{} ({})",
        clean_sentence(value, max_chars),
        entry_ref(entry_id)
    )
}

pub(super) fn contains_any(value: &str, needles: &[&str]) -> bool {
    needles.iter().any(|needle| value.contains(needle))
}

pub(super) fn push_unique(items: &mut Vec<String>, item: String) {
    if !item.is_empty() && !items.iter().any(|existing| existing == &item) {
        items.push(item);
    }
}

pub(super) fn extend_unique(items: &mut Vec<String>, incoming: impl IntoIterator<Item = String>) {
    for item in incoming {
        push_unique(items, item);
    }
}

pub(super) fn load_embedder<'a>(
    embedder: &'a mut Option<Embedder>,
    profiler: &mut CompactProfiler,
) -> Result<&'a Embedder> {
    if embedder.is_none() {
        let started = Instant::now();
        *embedder = Some(Embedder::load()?);
        profiler.record("bge load", started);
    }
    embedder
        .as_ref()
        .ok_or_else(|| CompactError::EmbedderUnavailable.into())
}

pub(super) fn directive_references<'a>(
    goals: &'a [String],
    outstanding: &'a [String],
) -> Vec<&'a str> {
    goals
        .iter()
        .chain(outstanding)
        .map(|item| strip_entry_ref(item))
        .filter(|item| !item.trim().is_empty())
        .collect()
}

pub(super) fn extract_recent(items: &mut Vec<String>, limit: usize, keep_first: bool) {
    if items.len() <= limit {
        return;
    }
    if keep_first {
        let tail_start = items.len() - (limit - 1);
        let mut capped = Vec::with_capacity(limit);
        capped.push(items[0].clone());
        capped.extend_from_slice(&items[tail_start..]);
        *items = capped;
    } else {
        let excess = items.len() - limit;
        items.drain(0..excess);
    }
}