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 →