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 — C++ port: compute a deterministic pruning plan for a
// token-budgeted chat history. C++17, stdlib only. Port of
// src/lib/conversationPruner.ts — same decisions as this dir's javascript.js.
#include <cmath>
#include <cstdio>
#include <stdexcept>
#include <string>
#include <vector>

// Summary compression model: fixed framing tokens, then 10% of the folded
// content's tokens, rounded up.
inline constexpr long long kSummaryFixedTokens = 60;
inline constexpr double kSummaryRatio = 0.1;

enum class ChatRole { System, User, Assistant, Tool };

// One message plus its prompt-side token count. Pinned messages are never
// dropped or summarized (the TypeScript's optional flag defaults to false).
struct ConversationMessage {
    ChatRole role;
    std::string content;
    long long tokens;
    bool pinned = false;
};

enum class PruneAction { Keep, Summarize, Drop };

// The plan's verdict on one message.
struct PruneDecision {
    std::size_t index;
    ChatRole role;
    PruneAction action;
    long long tokens;
};

// The full pruning plan for one conversation.
struct PrunePlan {
    std::vector<PruneDecision> decisions;
    long long keptTokens = 0;
    long long summarizedTokens = 0;
    long long droppedTokens = 0;
    long long summaryCostTokens = 0; // tokens the summary placeholder costs
    long long projectedTokens = 0;
    bool fitsBudget = false;
    std::vector<std::string> warnings;
};

const char *roleName(ChatRole r) {
    switch (r) {
        case ChatRole::System: return "system";
        case ChatRole::User: return "user";
        case ChatRole::Assistant: return "assistant";
        case ChatRole::Tool: return "tool";
    }
    return "?";
}

const char *actionName(PruneAction a) {
    switch (a) {
        case PruneAction::Keep: return "keep";
        case PruneAction::Summarize: return "summarize";
        case PruneAction::Drop: return "drop";
    }
    return "?";
}

// Group an integer with en-US commas: 1234567 -> "1,234,567", matching
// toLocaleString('en-US').
std::string thousands(long long v) {
    std::string digits = std::to_string(v < 0 ? -v : v);
    const std::size_t len = digits.size();
    std::string out;
    out.reserve(len + len / 3 + 1);
    if (v < 0) out.push_back('-');
    for (std::size_t i = 0; i < len; i++) {
        if (i > 0 && (len - i) % 3 == 0) out.push_back(',');
        out.push_back(digits[i]);
    }
    return out;
}

/**
 * Compute the pruning plan. Throws std::invalid_argument — the TypeScript
 * RangeError — on a negative budget or any negative per-message token count.
 */
PrunePlan planPrune(const std::vector<ConversationMessage> &messages, long long budgetTokens) {
    std::vector<std::string> warnings;
    if (budgetTokens < 0) throw std::invalid_argument("budgetTokens must be >= 0");
    for (const auto &m : messages) {
        if (m.tokens < 0) throw std::invalid_argument("message tokens must be >= 0");
    }

    const std::size_t n = messages.size();
    long long lastUser = -1;
    for (std::size_t k = n; k-- > 0;) {
        if (messages[k].role == ChatRole::User) {
            lastUser = static_cast<long long>(k);
            break;
        }
    }

    // Untouchable: every system message, pinned messages, the first turn (the
    // opening user request that anchors the conversation), and the current
    // request (the last user message and everything after it).
    std::vector<unsigned char> prot(n, 0);
    for (std::size_t i = 0; i < n; i++) {
        if (messages[i].role == ChatRole::System || messages[i].pinned) prot[i] = 1;
    }
    if (n > 0) prot[0] = 1;
    long long firstTurn = -1;
    for (std::size_t i = 0; i < n; i++) {
        if (messages[i].role != ChatRole::System) {
            firstTurn = static_cast<long long>(i);
            break;
        }
    }
    if (firstTurn != -1) prot[static_cast<std::size_t>(firstTurn)] = 1;
    std::size_t tailStart = static_cast<std::size_t>(
        lastUser == -1 ? static_cast<long long>(n > 0 ? n - 1 : 0) : lastUser);
    for (std::size_t i = tailStart; i < n; i++) prot[i] = 1;

    long long protectedTokens = 0;
    for (std::size_t i = 0; i < n; i++) {
        if (prot[i]) protectedTokens += messages[i].tokens;
    }
    if (protectedTokens > budgetTokens) {
        warnings.push_back(
            "Protected messages alone are " + thousands(protectedTokens) + " tokens against a " +
            thousands(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.
    std::vector<PruneAction> actions(n, PruneAction::Drop);
    for (std::size_t i = 0; i < n; i++) {
        if (prot[i]) actions[i] = PruneAction::Keep;
    }
    long long used = protectedTokens;
    for (std::size_t k = n; k-- > 0;) {
        if (actions[k] != PruneAction::Drop) continue;
        if (used + messages[k].tokens <= budgetTokens) {
            actions[k] = PruneAction::Keep;
            used += messages[k].tokens;
        } else {
            break; // oldest-unfilled remain drop/summarize candidates, newest first stopped
        }
    }

    // Everything still 'drop' in the middle folds into ONE running summary when
    // the compressed form fits where the raw turns did not.
    std::vector<std::size_t> summarizeIdx;
    for (std::size_t i = 0; i < n; i++) {
        if (actions[i] == PruneAction::Drop && !prot[i]) summarizeIdx.push_back(i);
    }
    long long summarizeTokens = 0;
    for (std::size_t i : summarizeIdx) summarizeTokens += messages[i].tokens;
    long long attemptedSummaryCost =
        summarizeIdx.empty()
            ? 0
            : kSummaryFixedTokens +
                  static_cast<long long>(std::ceil(static_cast<double>(summarizeTokens) * kSummaryRatio));

    // The summary only costs anything when it is actually applied — otherwise
    // those turns drop and cost zero.
    long long summaryCost = 0;
    if (attemptedSummaryCost > 0 && used + attemptedSummaryCost <= budgetTokens) {
        for (std::size_t i : summarizeIdx) actions[i] = PruneAction::Summarize;
        summaryCost = attemptedSummaryCost;
        // Mirrors the TypeScript; `used` is not consulted again afterwards.
        used += summaryCost;
        (void)used;
    } else if (attemptedSummaryCost > 0) {
        warnings.push_back("Even the compressed summary (" + thousands(attemptedSummaryCost) +
                           " tokens) does not fit the remaining budget — the oldest turns are "
                           "dropped instead.");
    }

    std::vector<PruneDecision> decisions;
    decisions.reserve(n);
    for (std::size_t i = 0; i < n; i++) {
        decisions.push_back(
            PruneDecision{i, messages[i].role, actions[i], messages[i].tokens});
    }

    long long keptTokens = 0, droppedTokens = 0, foldedTokens = 0;
    for (const auto &d : decisions) {
        if (d.action == PruneAction::Keep) keptTokens += d.tokens;
        else if (d.action == PruneAction::Drop) droppedTokens += d.tokens;
        else foldedTokens += d.tokens;
    }

    PrunePlan plan;
    plan.decisions = std::move(decisions);
    plan.keptTokens = keptTokens;
    plan.summarizedTokens = foldedTokens;
    plan.droppedTokens = droppedTokens;
    plan.summaryCostTokens = summaryCost;
    plan.projectedTokens = keptTokens + summaryCost;
    plan.fitsBudget = keptTokens + summaryCost <= budgetTokens;
    plan.warnings = std::move(warnings);
    return plan;
}

// Human-readable one-line summary of a plan.
std::string describePrune(const PrunePlan &plan) {
    if (!plan.fitsBudget) {
        return "Does not fit: " + thousands(plan.projectedTokens) +
               " tokens projected against the budget.";
    }
    std::vector<std::string> parts{thousands(plan.keptTokens) + " kept"};
    if (plan.summarizedTokens > 0) {
        parts.push_back(thousands(plan.summarizedTokens) + " folded into a " +
                        thousands(plan.summaryCostTokens) + "-token summary");
    }
    if (plan.droppedTokens > 0) {
        parts.push_back(thousands(plan.droppedTokens) + " dropped");
    }
    std::string out;
    for (std::size_t i = 0; i < parts.size(); i++) {
        if (i > 0) out += " · ";
        out += parts[i];
    }
    return out + " — fits the budget.";
}

int main() {
    std::vector<ConversationMessage> msgs = {
        {ChatRole::System, "You are CosmoDev's assistant.", 80, false},
        {ChatRole::User, "Please review my repo layout.", 250, false},
        {ChatRole::Assistant, "(long review of the tree)", 900, false},
        {ChatRole::Tool, "git status output", 400, false},
        {ChatRole::Assistant, "summary of the findings", 700, false},
        {ChatRole::User, "Now prune this conversation.", 350, false},
    };

    PrunePlan plan = planPrune(msgs, 1000);
    std::printf("budget 1,000:\n");
    for (const auto &d : plan.decisions) {
        std::printf("  %zu %-9s %-9s %5lld\n", d.index, roleName(d.role), actionName(d.action),
                    d.tokens);
    }
    for (const auto &w : plan.warnings) std::printf("  warning: %s\n", w.c_str());
    std::printf("  %s\n", describePrune(plan).c_str());

    // Squeezed budget: protected turns alone overflow, and even the
    // compressed summary does not fit.
    plan = planPrune(msgs, 50);
    std::printf("budget 50:\n");
    for (const auto &w : plan.warnings) std::printf("  warning: %s\n", w.c_str());
    std::printf("  %s\n", describePrune(plan).c_str());
    return 0;
}

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 →