Skip to content

Conversation Pruner — Java source

Plan how to fit a long chat history into a context budget — which turns to keep, fold into a summary, or drop, protecting system messages and the current request. 100% client-side.

This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Conversation Pruner — compute a deterministic pruning plan for a
// token-budgeted chat history.
//
// Language: Java (17+, zero dependencies)
// Port of src/lib/conversationPruner.ts (the canonical TypeScript
//           implementation) for the CosmoDev polyglot showcase
//           (slug: conversation-pruner).
//
// Given per-message token counts and a context budget, decide which messages
// to keep verbatim, which to fold into one running summary, and which to drop
// outright — protecting system messages, pinned turns, the first turn, and
// the current (last user) request.

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public final class ConversationPruner {

    /** Summary compression model: fixed framing tokens. */
    public static final int SUMMARY_FIXED_TOKENS = 60;

    /** Summary compression model: share of the folded content. */
    public static final double SUMMARY_RATIO = 0.1;

    public enum ChatRole { SYSTEM, USER, ASSISTANT, TOOL }

    public enum PruneAction { KEEP, SUMMARIZE, DROP }

    /** One chat message with its caller-supplied token count. */
    public record ConversationMessage(ChatRole role, String content, int tokens, boolean pinned) {
        public ConversationMessage(ChatRole role, String content, int tokens) {
            this(role, content, tokens, false);
        }
    }

    /** One per-message decision. */
    public record PruneDecision(int index, ChatRole role, PruneAction action, int tokens) {}

    /** The full plan: decisions, tallies, projection, warnings. */
    public record PrunePlan(
            List<PruneDecision> decisions,
            int keptTokens,
            int summarizedTokens,
            int droppedTokens,
            int summaryCostTokens,
            int projectedTokens,
            boolean fitsBudget,
            List<String> warnings) {}

    /**
     * Compute the pruning plan for {@code messages} under {@code budgetTokens}.
     * Throws {@link IllegalArgumentException} on a negative budget or any
     * negative per-message token count.
     */
    public static PrunePlan planPrune(List<ConversationMessage> messages, int budgetTokens) {
        List<String> warnings = new ArrayList<>();
        if (budgetTokens < 0)
            throw new IllegalArgumentException("budgetTokens must be >= 0");
        for (ConversationMessage m : messages)
            if (m.tokens() < 0)
                throw new IllegalArgumentException("message tokens must be >= 0");

        int n = messages.size();
        int lastUser = -1;
        for (int i = n - 1; i >= 0; i--)
            if (messages.get(i).role() == ChatRole.USER) { lastUser = i; break; }

        // Untouchable: every system message, pinned messages, the first turn
        // (the opening user request), and the current request (the last user
        // message and everything after it).
        java.util.Set<Integer> protectedIdx = new java.util.HashSet<>();
        for (int i = 0; i < n; i++)
            if (messages.get(i).role() == ChatRole.SYSTEM || messages.get(i).pinned())
                protectedIdx.add(i);
        if (n > 0) protectedIdx.add(0);
        int firstTurn = -1;
        for (int i = 0; i < n; i++)
            if (messages.get(i).role() != ChatRole.SYSTEM) { firstTurn = i; break; }
        if (firstTurn != -1) protectedIdx.add(firstTurn);
        for (int i = Math.max(lastUser == -1 ? n - 1 : lastUser, 0); i < n; i++)
            protectedIdx.add(i);

        int protectedTokens = 0;
        for (int i : protectedIdx) protectedTokens += messages.get(i).tokens();
        if (protectedTokens > budgetTokens)
            warnings.add(String.format(Locale.US,
                    "Protected messages alone are %,d tokens against a %,d budget — " +
                    "raise the budget (or reserve less for the reply) before pruning anything else.",
                    protectedTokens, budgetTokens));

        // Fill the remaining budget newest-to-oldest through the middle.
        PruneAction[] actions = new PruneAction[n];
        java.util.Arrays.fill(actions, PruneAction.DROP);
        for (int i : protectedIdx) actions[i] = PruneAction.KEEP;
        int used = protectedTokens;
        for (int i = n - 1; i >= 0; i--) {
            if (actions[i] != PruneAction.DROP) continue;
            if (used + messages.get(i).tokens() <= budgetTokens) {
                actions[i] = PruneAction.KEEP;
                used += messages.get(i).tokens();
            } else break; // oldest-unfilled remain drop/summarize candidates
        }

        // Everything still 'drop' in the middle folds into ONE running summary
        // when the compressed form fits where the raw turns did not.
        List<Integer> summarizeIdx = new ArrayList<>();
        for (int i = 0; i < n; i++)
            if (actions[i] == PruneAction.DROP && !protectedIdx.contains(i))
                summarizeIdx.add(i);
        int summarizeTokens = 0;
        for (int i : summarizeIdx) summarizeTokens += messages.get(i).tokens();
        int attempted = summarizeIdx.isEmpty() ? 0
                : SUMMARY_FIXED_TOKENS + (int) Math.ceil(summarizeTokens * SUMMARY_RATIO);

        // The summary only costs anything when it is actually applied.
        int summaryCost = 0;
        if (attempted > 0 && used + attempted <= budgetTokens) {
            for (int i : summarizeIdx) actions[i] = PruneAction.SUMMARIZE;
            summaryCost = attempted;
            used += summaryCost;
        } else if (attempted > 0)
            warnings.add(String.format(Locale.US,
                    "Even the compressed summary (%,d tokens) does not fit the remaining " +
                    "budget — the oldest turns are dropped instead.", attempted));

        List<PruneDecision> decisions = new ArrayList<>(n);
        for (int i = 0; i < n; i++)
            decisions.add(new PruneDecision(i, messages.get(i).role(), actions[i],
                    messages.get(i).tokens()));

        int kept = 0, dropped = 0, folded = 0;
        for (PruneDecision d : decisions) {
            if (d.action() == PruneAction.KEEP) kept += d.tokens();
            else if (d.action() == PruneAction.DROP) dropped += d.tokens();
            else folded += d.tokens();
        }

        return new PrunePlan(decisions, kept, folded, dropped, summaryCost,
                kept + summaryCost, kept + summaryCost <= budgetTokens, warnings);
    }

    /** Human-readable one-line summary of a plan. */
    public static String describePrune(PrunePlan plan) {
        if (!plan.fitsBudget())
            return String.format(Locale.US, "Does not fit: %,d tokens projected against the budget.",
                    plan.projectedTokens());
        List<String> parts = new ArrayList<>();
        parts.add(String.format(Locale.US, "%,d kept", plan.keptTokens()));
        if (plan.summarizedTokens() > 0)
            parts.add(String.format(Locale.US, "%,d folded into a %,d-token summary",
                    plan.summarizedTokens(), plan.summaryCostTokens()));
        if (plan.droppedTokens() > 0)
            parts.add(String.format(Locale.US, "%,d dropped", plan.droppedTokens()));
        return String.join(" · ", parts) + " — fits the budget.";
    }

    private ConversationPruner() {}
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →