Skip to content

Conversation Pruner — Zig 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 Zig 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: Zig (0.13+, stdlib only)
// 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. All output slices are allocated from the
// caller's allocator; free `warnings` strings and slices when done.

const std = @import("std");

/// Chat roles recognized by the pruner.
pub const Role = enum { system, user, assistant, tool };

/// One per-message decision.
pub const Action = enum { keep, summarize, drop };

/// One chat message with its caller-supplied token count.
pub const Message = struct {
    role: Role,
    content: []const u8,
    tokens: i64,
    /// Pinned messages are never dropped or summarized.
    pinned: bool = false,
};

/// One per-message decision record.
pub const Decision = struct {
    index: usize,
    role: Role,
    action: Action,
    tokens: i64,
};

/// The full plan: decisions, tallies, projection, warnings.
pub const Plan = struct {
    decisions: []Decision,
    kept_tokens: i64,
    summarized_tokens: i64,
    dropped_tokens: i64,
    /// Tokens the summary placeholder itself will cost in the prompt.
    summary_cost_tokens: i64,
    projected_tokens: i64,
    fits_budget: bool,
    /// Each string is allocated from the caller's allocator.
    warnings: [][]const u8,
};

/// Summary compression model: fixed framing tokens.
pub const summary_fixed_tokens: i64 = 60;

/// Summary compression model: share of the folded content.
pub const summary_ratio: f64 = 0.1;

pub const PruneError = error{
    NegativeBudget,
    NegativeTokens,
    OutOfMemory,
};

/// Group a non-negative integer with thousands separators
/// (toLocaleString stand-in). `buf` must hold 16 bytes.
fn commaFormat(buf: []u8, v: i64) []const u8 {
    var tmp: [24]u8 = undefined;
    const digits = std.fmt.bufPrint(&tmp, "{d}", .{v}) catch unreachable;
    var out_len: usize = 0;
    const n = digits.len;
    for (digits, 0..) |ch, i| {
        buf[out_len] = ch;
        out_len += 1;
        const remaining = n - i - 1;
        if (remaining > 0 and remaining % 3 == 0) {
            buf[out_len] = ',';
            out_len += 1;
        }
    }
    return buf[0..out_len];
}

fn warnProtectedTooBig(alloc: std.mem.Allocator, protected: i64, budget: i64) ![]const u8 {
    var b1: [24]u8 = undefined;
    var b2: [24]u8 = undefined;
    return std.fmt.allocPrint(alloc,
        "Protected messages alone are {s} tokens against a {s} budget — raise the budget " ++
            "(or reserve less for the reply) before pruning anything else.",
        .{ commaFormat(&b1, protected), commaFormat(&b2, budget) });
}

fn warnSummaryDoesNotFit(alloc: std.mem.Allocator, attempted: i64) ![]const u8 {
    var b: [24]u8 = undefined;
    return std.fmt.allocPrint(alloc,
        "Even the compressed summary ({s} tokens) does not fit the remaining budget — " ++
            "the oldest turns are dropped instead.",
        .{commaFormat(&b, attempted)});
}

/// Compute the pruning plan for `messages` under `budget_tokens`.
/// Mirrors planPrune() in the TS lib, including the newest-to-oldest fill
/// with its early `break`, and the single-fold summary attempt.
pub fn planPrune(
    alloc: std.mem.Allocator,
    messages: []const Message,
    budget_tokens: i64,
) PruneError!Plan {
    if (budget_tokens < 0) return error.NegativeBudget;
    for (messages) |m| {
        if (m.tokens < 0) return error.NegativeTokens;
    }

    const n = messages.len;
    var warnings: std.ArrayList([]const u8) = .init(alloc);
    errdefer {
        for (warnings.items) |w| alloc.free(w);
        warnings.deinit();
    }

    var last_user: ?usize = null;
    var i: usize = n;
    while (i > 0) {
        i -= 1;
        if (messages[i].role == .user) {
            last_user = 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).
    const protected = try alloc.alloc(bool, n);
    defer alloc.free(protected);
    @memset(protected, false);
    for (messages, 0..) |m, idx| {
        if (m.role == .system or m.pinned) protected[idx] = true;
    }
    if (n > 0) protected[0] = true;
    for (messages, 0..) |m, idx| {
        if (m.role != .system) {
            protected[idx] = true;
            break;
        }
    }
    const tail_from = last_user orelse (if (n > 0) n - 1 else 0);
    var t: usize = tail_from;
    while (t < n) : (t += 1) protected[t] = true;

    var protected_tokens: i64 = 0;
    for (messages, 0..) |m, idx| {
        if (protected[idx]) protected_tokens += m.tokens;
    }
    if (protected_tokens > budget_tokens) {
        try warnings.append(try warnProtectedTooBig(alloc, protected_tokens, budget_tokens));
    }

    // Fill the remaining budget newest-to-oldest through the middle.
    const actions = try alloc.alloc(Action, n);
    defer alloc.free(actions);
    @memset(actions, .drop);
    for (protected, 0..) |p, idx| {
        if (p) actions[idx] = .keep;
    }
    var used = protected_tokens;
    var j: usize = n;
    while (j > 0) {
        j -= 1;
        if (actions[j] != .drop) continue;
        if (used + messages[j].tokens <= budget_tokens) {
            actions[j] = .keep;
            used += messages[j].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 summarize_tokens: i64 = 0;
    var summarize_count: usize = 0;
    for (actions, 0..) |a, idx| {
        if (a == .drop and !protected[idx]) {
            summarize_tokens += messages[idx].tokens;
            summarize_count += 1;
        }
    }
    const attempted: i64 = if (summarize_count > 0)
        summary_fixed_tokens + @as(i64, @intFromFloat(@ceil(@as(f64, @floatFromInt(summarize_tokens)) * summary_ratio)))
    else
        0;

    // The summary only costs anything when it is actually applied.
    var summary_cost: i64 = 0;
    if (attempted > 0 and used + attempted <= budget_tokens) {
        for (actions, 0..) |*a, idx| {
            if (a.* == .drop and !protected[idx]) a.* = .summarize;
        }
        summary_cost = attempted;
        used += summary_cost;
    } else if (attempted > 0) {
        try warnings.append(try warnSummaryDoesNotFit(alloc, attempted));
    }

    const decisions = try alloc.alloc(Decision, n);
    for (messages, 0..) |m, idx| {
        decisions[idx] = .{
            .index = idx,
            .role = m.role,
            .action = actions[idx],
            .tokens = m.tokens,
        };
    }

    var kept: i64 = 0;
    var dropped: i64 = 0;
    var folded: i64 = 0;
    for (decisions) |d| {
        switch (d.action) {
            .keep => kept += d.tokens,
            .drop => dropped += d.tokens,
            .summarize => folded += d.tokens,
        }
    }

    return .{
        .decisions = decisions,
        .kept_tokens = kept,
        .summarized_tokens = folded,
        .dropped_tokens = dropped,
        .summary_cost_tokens = summary_cost,
        .projected_tokens = kept + summary_cost,
        .fits_budget = kept + summary_cost <= budget_tokens,
        .warnings = try warnings.toOwnedSlice(),
    };
}

/// Free a plan's allocated slices (decisions + warnings strings).
pub fn freePlan(alloc: std.mem.Allocator, plan: Plan) void {
    alloc.free(plan.decisions);
    for (plan.warnings) |w| alloc.free(w);
    alloc.free(plan.warnings);
}

/// Human-readable one-line summary of a plan. Caller owns the returned string.
pub fn describePrune(alloc: std.mem.Allocator, plan: Plan) ![]const u8 {
    if (!plan.fits_budget) {
        var b: [24]u8 = undefined;
        return std.fmt.allocPrint(alloc, "Does not fit: {s} tokens projected against the budget.", .{
            commaFormat(&b, plan.projected_tokens),
        });
    }
    var b1: [24]u8 = undefined;
    var b2: [24]u8 = undefined;
    var out: std.ArrayList(u8) = .init(alloc);
    errdefer out.deinit();
    try out.writer().print("{s} kept", .{commaFormat(&b1, plan.kept_tokens)});
    if (plan.summarized_tokens > 0) {
        try out.writer().print(" · {s} folded into a {s}-token summary", .{
            commaFormat(&b1, plan.summarized_tokens),
            commaFormat(&b2, plan.summary_cost_tokens),
        });
    }
    if (plan.dropped_tokens > 0) {
        try out.writer().print(" · {s} dropped", .{commaFormat(&b1, plan.dropped_tokens)});
    }
    try out.appendSlice(" — fits the budget.");
    return out.toOwnedSlice();
}

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 →