Skip to content

Conversation Pruner — Swift 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 Swift 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: Swift (5.9+, 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.

/// Chat roles recognized by the pruner.
enum ChatRole: String {
    case system, user, assistant, tool
}

/// One per-message decision.
enum PruneAction: String {
    case keep, summarize, drop
}

/// One chat message with its caller-supplied token count.
struct ConversationMessage {
    var role: ChatRole
    var content: String
    var tokens: Int
    /// Pinned messages are never dropped or summarized.
    var pinned: Bool = false
}

/// One per-message decision record.
struct PruneDecision {
    var index: Int
    var role: ChatRole
    var action: PruneAction
    var tokens: Int
}

/// The full plan: decisions, tallies, projection, warnings.
struct PrunePlan {
    var decisions: [PruneDecision]
    var keptTokens: Int
    var summarizedTokens: Int
    var droppedTokens: Int
    /// Tokens the summary placeholder itself will cost in the prompt.
    var summaryCostTokens: Int
    var projectedTokens: Int
    var fitsBudget: Bool
    var warnings: [String]
}

/// Summary compression model: fixed framing tokens.
let SUMMARY_FIXED_TOKENS = 60

/// Summary compression model: share of the folded content.
let SUMMARY_RATIO = 0.1

/// Group an integer with thousands separators (toLocaleString stand-in).
private func fmt(_ v: Int) -> String {
    let s = String(v)
    var out = ""
    for (offset, ch) in s.enumerated().reversed() {
        out.append(ch)
        let remaining = s.count - offset - 1
        if remaining > 0 && remaining % 3 == 0 { out.append(",") }
    }
    return String(out.reversed())
}

/// Errors mirroring the TS suite's RangeError contract.
enum PruneError: Error, CustomStringConvertible {
    case negativeBudget
    case negativeTokens

    var description: String {
        switch self {
        case .negativeBudget: return "budgetTokens must be >= 0"
        case .negativeTokens: return "message tokens must be >= 0"
        }
    }
}

/// Compute the pruning plan for `messages` under `budgetTokens`.
func planPrune(_ messages: [ConversationMessage], budgetTokens: Int) throws -> PrunePlan {
    var warnings: [String] = []
    if budgetTokens < 0 { throw PruneError.negativeBudget }
    if messages.contains(where: { $0.tokens < 0 }) { throw PruneError.negativeTokens }

    let n = messages.count
    let lastUser = messages.lastIndex(where: { $0.role == .user }) ?? -1

    // 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 = Set<Int>()
    for (i, m) in messages.enumerated() {
        if m.role == .system || m.pinned { protectedIdx.insert(i) }
    }
    if n > 0 { protectedIdx.insert(0) }
    if let firstTurn = messages.firstIndex(where: { $0.role != .system }) {
        protectedIdx.insert(firstTurn)
    }
    for i in max(lastUser == -1 ? n - 1 : lastUser, 0)..<n { protectedIdx.insert(i) }

    let protectedTokens = protectedIdx.reduce(0) { $0 + messages[$1].tokens }
    if protectedTokens > budgetTokens {
        warnings.append(
            "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 = Array(repeating: PruneAction.drop, count: n)
    for i in protectedIdx { actions[i] = .keep }
    var used = protectedTokens
    for i in stride(from: n - 1, through: 0, by: -1) {
        if actions[i] != .drop { continue }
        if used + messages[i].tokens <= budgetTokens {
            actions[i] = .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.
    let summarizeIdx = (0..<n).filter { actions[$0] == .drop && !protectedIdx.contains($0) }
    let summarizeTokens = summarizeIdx.reduce(0) { $0 + messages[$1].tokens }
    let attempted = summarizeIdx.isEmpty
        ? 0
        : SUMMARY_FIXED_TOKENS + Int((Double(summarizeTokens) * SUMMARY_RATIO).rounded(.up))

    // The summary only costs anything when it is actually applied.
    var summaryCost = 0
    if attempted > 0 && used + attempted <= budgetTokens {
        for i in summarizeIdx { actions[i] = .summarize }
        summaryCost = attempted
        used += summaryCost
    } else if attempted > 0 {
        warnings.append(
            "Even the compressed summary (\(fmt(attempted)) tokens) does not fit the " +
            "remaining budget — the oldest turns are dropped instead.")
    }

    let decisions = messages.enumerated().map { i, m in
        PruneDecision(index: i, role: m.role, action: actions[i], tokens: m.tokens)
    }

    var kept = 0, dropped = 0, folded = 0
    for d in decisions {
        switch d.action {
        case .keep: kept += d.tokens
        case .drop: dropped += d.tokens
        case .summarize: folded += d.tokens
        }
    }

    return PrunePlan(
        decisions: decisions,
        keptTokens: kept,
        summarizedTokens: folded,
        droppedTokens: dropped,
        summaryCostTokens: summaryCost,
        projectedTokens: kept + summaryCost,
        fitsBudget: kept + summaryCost <= budgetTokens,
        warnings: warnings)
}

/// Human-readable one-line summary of a plan.
func describePrune(_ plan: PrunePlan) -> String {
    if !plan.fitsBudget {
        return "Does not fit: \(fmt(plan.projectedTokens)) tokens projected against the budget."
    }
    var parts = ["\(fmt(plan.keptTokens)) kept"]
    if plan.summarizedTokens > 0 {
        parts.append(
            "\(fmt(plan.summarizedTokens)) folded into a \(fmt(plan.summaryCostTokens))-token summary")
    }
    if plan.droppedTokens > 0 { parts.append("\(fmt(plan.droppedTokens)) dropped") }
    return parts.joined(separator: " · ") + " — fits the budget."
}

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 →