Skip to content

Conversation Pruner — PHP 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 PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * Conversation Pruner — compute a deterministic pruning plan for a
 * token-budgeted chat history.
 *
 * Language: PHP (8.1+, standard library only)
 * Source:   CosmoDev polyglot showcase port of the Conversation Pruner
 *           tool (slug: conversation-pruner).
 * Port of src/lib/conversationPruner.ts (the canonical TypeScript
 *           implementation).
 * Tool page: https://dev.cosmolabs.org/tools/conversation-pruner
 * License:  display source — part of CosmoDev's polyglot tool pages.
 *
 * 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 (the opening user request), and the current (last user)
 * request. Array keys keep the TypeScript field names (keptTokens,
 * summaryCostTokens, …) so plans serialize to the same shape.
 */

declare(strict_types=1);

/**
 * Summary compression model: fixed framing tokens, then 10% of the folded
 * content's tokens, rounded up.
 */
const SUMMARY_FIXED_TOKENS = 60;
const SUMMARY_RATIO = 0.1;

/**
 * Compute the pruning plan.
 *
 * Each message is an array with keys role ('system' | 'user' | 'assistant'
 * | 'tool'), content, tokens, and optional pinned (default false). Throws
 * RangeException — the TypeScript's RangeError — on a negative budget or
 * any negative per-message token count. The returned plan carries one
 * {index, role, action, tokens} decision per message plus the tallies and
 * warnings.
 */
function plan_prune(array $messages, int $budget_tokens): array
{
    $warnings = [];
    if ($budget_tokens < 0) {
        throw new \RangeException('budgetTokens must be >= 0');
    }
    foreach ($messages as $m) {
        if ($m['tokens'] < 0) {
            throw new \RangeException('message tokens must be >= 0');
        }
    }

    $n = count($messages);
    $lastUser = -1;
    for ($i = $n - 1; $i >= 0; $i--) {
        if ($messages[$i]['role'] === 'user') {
            $lastUser = $i;
            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).
    $protected = [];
    foreach ($messages as $i => $m) {
        if ($m['role'] === 'system' || ($m['pinned'] ?? false)) {
            $protected[$i] = true;
        }
    }
    if ($n > 0) {
        $protected[0] = true;
    }
    $firstTurn = -1;
    foreach ($messages as $i => $m) {
        if ($m['role'] !== 'system') {
            $firstTurn = $i;
            break;
        }
    }
    if ($firstTurn !== -1) {
        $protected[$firstTurn] = true;
    }
    $tailStart = max($lastUser === -1 ? $n - 1 : $lastUser, 0);
    for ($i = $tailStart; $i < $n; $i++) {
        $protected[$i] = true;
    }

    $protectedTokens = 0;
    foreach (array_keys($protected) as $i) {
        $protectedTokens += $messages[$i]['tokens'];
    }
    if ($protectedTokens > $budget_tokens) {
        $warnings[] = 'Protected messages alone are ' . number_format($protectedTokens)
            . ' tokens against a ' . number_format($budget_tokens)
            . " budget — raise the budget (or reserve less for the reply) before pruning anything else.";
    }

    // Fill the remaining budget newest-to-oldest through the middle.
    $actions = array_fill(0, $n, 'drop');
    foreach (array_keys($protected) as $i) {
        $actions[$i] = 'keep';
    }
    $used = $protectedTokens;
    for ($i = $n - 1; $i >= 0; $i--) {
        if ($actions[$i] !== 'drop') {
            continue;
        }
        if ($used + $messages[$i]['tokens'] <= $budget_tokens) {
            $actions[$i] = 'keep';
            $used += $messages[$i]['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.
    $summarizeIdx = [];
    foreach ($actions as $i => $a) {
        if ($a === 'drop' && !isset($protected[$i])) {
            $summarizeIdx[] = $i;
        }
    }
    $summarizeTokens = 0;
    foreach ($summarizeIdx as $i) {
        $summarizeTokens += $messages[$i]['tokens'];
    }
    $attemptedSummaryCost = count($summarizeIdx) > 0
        ? SUMMARY_FIXED_TOKENS + (int) ceil($summarizeTokens * SUMMARY_RATIO)
        : 0;

    // The summary only costs anything when it is actually applied — otherwise
    // those turns drop and cost zero.
    $summaryCost = 0;
    if ($attemptedSummaryCost > 0 && $used + $attemptedSummaryCost <= $budget_tokens) {
        foreach ($summarizeIdx as $i) {
            $actions[$i] = 'summarize';
        }
        $summaryCost = $attemptedSummaryCost;
        $used += $summaryCost;
    } elseif ($attemptedSummaryCost > 0) {
        $warnings[] = 'Even the compressed summary (' . number_format($attemptedSummaryCost)
            . " tokens) does not fit the remaining budget — the oldest turns are dropped instead.";
    }

    $decisions = [];
    foreach ($messages as $i => $m) {
        $decisions[] = [
            'index' => $i,
            'role' => $m['role'],
            'action' => $actions[$i],
            'tokens' => $m['tokens'],
        ];
    }

    $keptTokens = 0;
    $droppedTokens = 0;
    $foldedTokens = 0;
    foreach ($decisions as $d) {
        if ($d['action'] === 'keep') {
            $keptTokens += $d['tokens'];
        } elseif ($d['action'] === 'drop') {
            $droppedTokens += $d['tokens'];
        } else {
            $foldedTokens += $d['tokens'];
        }
    }

    return [
        'decisions' => $decisions,
        'keptTokens' => $keptTokens,
        'summarizedTokens' => $foldedTokens,
        'droppedTokens' => $droppedTokens,
        'summaryCostTokens' => $summaryCost,
        'projectedTokens' => $keptTokens + $summaryCost,
        'fitsBudget' => $keptTokens + $summaryCost <= $budget_tokens,
        'warnings' => $warnings,
    ];
}

/**
 * Human-readable one-line summary of a plan.
 */
function describe_prune(array $plan): string
{
    if (!$plan['fitsBudget']) {
        return 'Does not fit: ' . number_format($plan['projectedTokens'])
            . ' tokens projected against the budget.';
    }
    $parts = [number_format($plan['keptTokens']) . ' kept'];
    if ($plan['summarizedTokens'] > 0) {
        $parts[] = number_format($plan['summarizedTokens']) . ' folded into a '
            . number_format($plan['summaryCostTokens']) . '-token summary';
    }
    if ($plan['droppedTokens'] > 0) {
        $parts[] = number_format($plan['droppedTokens']) . ' dropped';
    }
    return implode(' · ', $parts) . ' — 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 →