Skip to content

Conversation Pruner — C# 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 C# 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: C# (.NET 8, 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.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CosmoDev.ConversationPruner;

/// <summary>Chat roles recognized by the pruner.</summary>
public enum ChatRole { System, User, Assistant, Tool }
public enum PruneAction { Keep, Summarize, Drop }

public sealed record ConversationMessage(
    ChatRole Role,
    string Content,
    int Tokens,
    bool Pinned = false);

public sealed record PruneDecision(
    int Index,
    ChatRole Role,
    PruneAction Action,
    int Tokens);

public sealed record PrunePlan(
    IReadOnlyList<PruneDecision> Decisions,
    int KeptTokens,
    int SummarizedTokens,
    int DroppedTokens,
    int SummaryCostTokens,
    int ProjectedTokens,
    bool FitsBudget,
    IReadOnlyList<string> Warnings);

public static class ConversationPruner
{
    /// <summary>Summary compression model: fixed framing tokens.</summary>
    public const int SUMMARY_FIXED_TOKENS = 60;

    /// <summary>Summary compression model: share of the folded content.</summary>
    public const double SUMMARY_RATIO = 0.1;

    /// <summary>
    /// Compute the pruning plan for <paramref name="messages"/> under
    /// <paramref name="budgetTokens"/>. Throws <see cref="ArgumentOutOfRangeException"/>
    /// on a negative budget or any negative per-message token count.
    /// </summary>
    public static PrunePlan PlanPrune(
        IReadOnlyList<ConversationMessage> messages, int budgetTokens)
    {
        var warnings = new List<string>();
        if (budgetTokens < 0)
            throw new ArgumentOutOfRangeException(nameof(budgetTokens),
                "budgetTokens must be >= 0");
        if (messages.Any(m => m.Tokens < 0))
            throw new ArgumentException("message tokens must be >= 0");

        int n = messages.Count;
        int LastIndexWithRole(ChatRole role)
        {
            for (int i = n - 1; i >= 0; i--) if (messages[i].Role == role) return i;
            return -1;
        }
        int lastUser = LastIndexWithRole(ChatRole.User);

        // 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).
        var protectedIdx = new HashSet<int>();
        for (int i = 0; i < n; i++)
            if (messages[i].Role == ChatRole.System || messages[i].Pinned)
                protectedIdx.Add(i);
        if (n > 0) protectedIdx.Add(0);
        int firstTurn = Enumerable.Range(0, n).FirstOrDefault(i => messages[i].Role != ChatRole.System, -1);
        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 = protectedIdx.Sum(i => messages[i].Tokens);
        if (protectedTokens > budgetTokens)
            warnings.Add(
                $"Protected messages alone are {Fmt(protectedTokens)} tokens against a " +
                $"{Fmt(budgetTokens)} budget — raise the budget (or reserve less for the reply) before pruning anything else.");

        // Fill the remaining budget newest-to-oldest through the middle.
        var actions = new PruneAction[n];
        Array.Fill(actions, PruneAction.Drop);
        foreach (int i in 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[i].Tokens <= budgetTokens)
            {
                actions[i] = PruneAction.Keep;
                used += messages[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.
        var summarizeIdx = Enumerable.Range(0, n)
            .Where(i => actions[i] == PruneAction.Drop && !protectedIdx.Contains(i))
            .ToList();
        int summarizeTokens = summarizeIdx.Sum(i => messages[i].Tokens);
        int attempted =
            summarizeIdx.Count > 0
                ? SUMMARY_FIXED_TOKENS + (int)Math.Ceiling(summarizeTokens * SUMMARY_RATIO)
                : 0;

        // The summary only costs anything when it is actually applied.
        int summaryCost = 0;
        if (attempted > 0 && used + attempted <= budgetTokens)
        {
            foreach (int i in summarizeIdx) actions[i] = PruneAction.Summarize;
            summaryCost = attempted;
            used += summaryCost;
        }
        else if (attempted > 0)
        {
            warnings.Add(
                $"Even the compressed summary ({Fmt(attempted)} tokens) does not fit " +
                "the remaining budget — the oldest turns are dropped instead.");
        }

        var decisions = messages.Select((m, i) =>
            new PruneDecision(i, m.Role, actions[i], m.Tokens)).ToList();

        int kept = 0, dropped = 0, folded = 0;
        foreach (var d in 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);
    }

    /// <summary>Human-readable one-line summary of a plan.</summary>
    public static string DescribePrune(PrunePlan plan)
    {
        if (!plan.FitsBudget)
            return $"Does not fit: {Fmt(plan.ProjectedTokens)} tokens projected against the budget.";
        var parts = new List<string> { $"{Fmt(plan.KeptTokens)} kept" };
        if (plan.SummarizedTokens > 0)
            parts.Add($"{Fmt(plan.SummarizedTokens)} folded into a {Fmt(plan.SummaryCostTokens)}-token summary");
        if (plan.DroppedTokens > 0) parts.Add($"{Fmt(plan.DroppedTokens)} dropped");
        return $"{string.Join(" · ", parts)} — fits the budget.";
    }

    private static string Fmt(int v) => v.ToString("N0");
}

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 →