goosedump 0.2.0

Coding agent context data browser
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

use crate::message::{AssistantResponse, ConversationMessage, MessageKind, TextContent};
use crate::{search, text};
use std::fmt::Write as _;

const RECENT_MESSAGE_COUNT: usize = 30;
const SUMMARY_SEARCH_PAGE_SIZE: usize = 12;
const GOAL_QUERY: &str = "goal objective task purpose working implementing building creating fixing adding update upgrade migrate";
const STATUS_QUERY: &str = "status progress done completed finished resolved fixed todo pending remaining left in progress wip";
const OUTSTANDING_QUERY: &str = "todo pending remaining left wip bug issue fixme";
const DECISION_QUERY: &str =
    "decided decision chosen opted settled agreed will use switch change keep stay go with";
const CONSTRAINT_QUERY: &str = "cannot can't must not should not don't do not never constraint restriction limitation requirement preserve avoid";

#[derive(Debug)]
struct ExtractedContext {
    goal: String,
    outstanding: Vec<String>,
    references: Vec<String>,
    decisions: Vec<String>,
    constraints: Vec<String>,
    status: String,
}

pub fn plain_summary(messages: &[ConversationMessage]) -> String {
    let ctx = extract_context(messages);
    format_summary(&ctx)
}

fn extract_context(messages: &[ConversationMessage]) -> ExtractedContext {
    let summary_messages = narrative_messages(messages);
    let recent_start = summary_messages.len().saturating_sub(RECENT_MESSAGE_COUNT);
    let recent_messages = &summary_messages[recent_start..];

    let goal = first_user_text(&summary_messages)
        .or_else(|| first_search_line(&summary_messages, GOAL_QUERY))
        .unwrap_or_else(|| "Ongoing development work".to_string());

    let status = search_lines(recent_messages, STATUS_QUERY, 1)
        .into_iter()
        .next()
        .map_or_else(
            || "In progress".to_string(),
            |status| concise_status(&status),
        );

    ExtractedContext {
        goal,
        outstanding: outstanding_items(recent_messages, &status),
        references: references(&summary_messages),
        decisions: search_lines(recent_messages, DECISION_QUERY, 6),
        constraints: constraints(&summary_messages),
        status,
    }
}

fn narrative_messages(messages: &[ConversationMessage]) -> Vec<ConversationMessage> {
    messages
        .iter()
        .filter_map(|message| match &message.kind {
            MessageKind::TextContent(text_content) => {
                keep_text_message(&message.entry_id, &text_content.role, &text_content.text)
            }
            MessageKind::AssistantResponse(response) => {
                keep_assistant_message(&message.entry_id, response)
            }
            MessageKind::ToolResultData(_) | MessageKind::BashOutput(_) => None,
        })
        .collect()
}

fn keep_text_message(entry_id: &str, role: &str, value: &str) -> Option<ConversationMessage> {
    let text = text::sanitize(value);
    if text.trim().is_empty() {
        return None;
    }

    Some(ConversationMessage {
        entry_id: entry_id.to_string(),
        kind: MessageKind::TextContent(TextContent {
            role: role.to_string(),
            text,
        }),
    })
}

fn keep_assistant_message(
    entry_id: &str,
    response: &AssistantResponse,
) -> Option<ConversationMessage> {
    keep_text_message(entry_id, "assistant", &response.text)
}

fn first_user_text(messages: &[ConversationMessage]) -> Option<String> {
    messages.iter().find_map(|message| {
        let MessageKind::TextContent(text_content) = &message.kind else {
            return None;
        };

        if text_content.role != "user" {
            return None;
        }

        clean_line(&text_content.text, 200)
    })
}

fn outstanding_items(messages: &[ConversationMessage], status: &str) -> Vec<String> {
    if is_complete_status(status) {
        return default_outstanding_items();
    }

    let items = search_lines(messages, OUTSTANDING_QUERY, 8);
    if items.is_empty() {
        default_outstanding_items()
    } else {
        items
    }
}

fn constraints(messages: &[ConversationMessage]) -> Vec<String> {
    let items = search_lines(messages, CONSTRAINT_QUERY, 6);
    if items.is_empty() {
        vec!["No specific constraints identified".to_string()]
    } else {
        items
    }
}

fn is_complete_status(status: &str) -> bool {
    let status = status.to_ascii_lowercase();
    status.contains("all tasks completed")
        || status.contains("all tasks complete")
        || status.contains("no outstanding")
}

fn concise_status(status: &str) -> String {
    if is_complete_status(status) {
        "All tasks completed".to_string()
    } else {
        status.to_string()
    }
}

fn first_search_line(messages: &[ConversationMessage], query: &str) -> Option<String> {
    search_lines(messages, query, 1).into_iter().next()
}

fn search_lines(messages: &[ConversationMessage], query: &str, limit: usize) -> Vec<String> {
    if messages.is_empty() || limit == 0 {
        return Vec::new();
    }

    let page_size = SUMMARY_SEARCH_PAGE_SIZE.max(limit * 2);
    let query_terms = query_terms(query);
    let (hits, _) = search::query(messages, query, 1, page_size);
    let mut lines = Vec::new();

    for hit in hits {
        if let Some(line) = best_content_line(&hit.text, &query_terms)
            && !looks_like_search_context(&line)
        {
            push_unique(&mut lines, line);
        }

        if lines.len() >= limit {
            break;
        }
    }

    lines
}

fn best_content_line(snippet: &str, query_terms: &[String]) -> Option<String> {
    snippet
        .lines()
        .filter_map(|line| clean_line(line, 120))
        .find(|line| line_matches_terms(line, query_terms))
}

fn query_terms(query: &str) -> Vec<String> {
    text::split_words(query)
        .into_iter()
        .filter(|word| word.len() > 1 && !text::is_stop_word(word))
        .collect()
}

fn line_matches_terms(line: &str, terms: &[String]) -> bool {
    let line_words: Vec<String> = text::split_words(line)
        .iter()
        .map(|word| summary_match_word(word))
        .collect();

    terms
        .iter()
        .any(|term| line_words.iter().any(|word| word == term))
}

fn summary_match_word(word: &str) -> String {
    word.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '-')
        .to_string()
}

fn clean_line(value: &str, max_chars: usize) -> Option<String> {
    let line = value.trim();
    if line.is_empty() || line.starts_with("...(") {
        return None;
    }

    Some(text::clip(line, max_chars))
}

fn looks_like_search_context(line: &str) -> bool {
    let line = line.trim();
    line.starts_with("tools:") || line.starts_with("score:")
}

fn references(messages: &[ConversationMessage]) -> Vec<String> {
    let mut refs = Vec::new();

    for message in messages {
        let MessageKind::TextContent(text_content) = &message.kind else {
            continue;
        };

        for token in text_content.text.split_whitespace() {
            let token = clean_reference_token(token);
            if is_reference_token(&token) {
                push_unique(&mut refs, token);
                if refs.len() >= 15 {
                    return refs;
                }
            }
        }
    }

    refs
}

fn clean_reference_token(token: &str) -> String {
    token
        .trim_matches(|c: char| matches!(c, ',' | ';' | ':' | ')' | '(' | '[' | ']' | '{' | '}'))
        .trim_matches('"')
        .trim_matches('\'')
        .to_string()
}

fn is_reference_token(token: &str) -> bool {
    if token.len() < 3 {
        return false;
    }

    token.starts_with("http://")
        || token.starts_with("https://")
        || token.starts_with("../")
        || token.starts_with("./")
        || token.starts_with('/')
        || token.contains('/')
        || (token.starts_with('#') && token[1..].chars().all(|c| c.is_ascii_digit()))
        || is_version_token(token)
        || is_code_file_token(token)
}

fn is_version_token(token: &str) -> bool {
    let token = token.strip_prefix('v').unwrap_or(token);
    let mut parts = token.split('.');
    let (Some(major), Some(minor)) = (parts.next(), parts.next()) else {
        return false;
    };

    major.chars().all(|c| c.is_ascii_digit())
        && minor.chars().all(|c| c.is_ascii_digit())
        && parts.all(|part| {
            part.chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
        })
}

fn is_code_file_token(token: &str) -> bool {
    let token = token.trim_matches('`');
    [".rs", ".ts", ".js", ".json", ".toml", ".md", ".py"]
        .iter()
        .any(|suffix| token.ends_with(suffix))
}

fn default_outstanding_items() -> Vec<String> {
    vec!["No outstanding items identified".to_string()]
}

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

fn format_summary(ctx: &ExtractedContext) -> String {
    let mut sections = Vec::new();

    sections.push(format!("## Session Goal\n{}", ctx.goal));

    if !ctx.status.is_empty() {
        sections.push(format!("## Status\n{}", ctx.status));
    }

    if !ctx.outstanding.is_empty() {
        sections.push(format!(
            "## Outstanding Context\n{}",
            bullet_list(&ctx.outstanding)
        ));
    }

    if !ctx.decisions.is_empty() {
        sections.push(format!("## Key Decisions\n{}", bullet_list(&ctx.decisions)));
    }

    if !ctx.constraints.is_empty() {
        sections.push(format!("## Constraints\n{}", bullet_list(&ctx.constraints)));
    }

    if !ctx.references.is_empty() {
        sections.push(format!("## References\n{}", bullet_list(&ctx.references)));
    }

    sections.join("\n\n")
}

fn bullet_list(items: &[String]) -> String {
    let mut out = String::new();
    for item in items {
        let _ = writeln!(out, "- {item}");
    }
    out.trim_end().to_string()
}