Conversation Pruner — JavaScript 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 JavaScript 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: JavaScript (ES2022+, ES module; runs unmodified in Node 18+
* and modern browsers)
* 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. Token counts are plain integers; the only float math is the
* summary cost: fixed framing plus 10% of the folded content, rounded up.
*/
/** Summary compression model: fixed framing tokens. */
export const SUMMARY_FIXED_TOKENS = 60;
/** Summary compression model: share of the folded content's tokens. */
export const SUMMARY_RATIO = 0.1;
/**
* Compute the pruning plan for `messages` under `opts.budgetTokens`.
* Each message is `{ role, content, tokens, pinned? }` with role one of
* 'system' | 'user' | 'assistant' | 'tool'. Throws a RangeError on a
* negative budget or any negative per-message token count. The returned
* plan carries one `{ index, role, action, tokens }` decision per message
* ('keep' | 'summarize' | 'drop'), the token tallies, the projected total,
* whether it fits, and any warnings.
*/
export function planPrune(messages, opts) {
const warnings = [];
if (opts.budgetTokens < 0) throw new RangeError('budgetTokens must be >= 0');
if (messages.some((m) => m.tokens < 0)) throw new RangeError('message tokens must be >= 0');
const n = messages.length;
const lastIndexWithRole = (role) => {
for (let i = n - 1; i >= 0; i--) if (messages[i].role === role) return i;
return -1;
};
const lastUser = lastIndexWithRole('user');
// 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).
const protectedIdx = new Set();
messages.forEach((m, i) => {
if (m.role === 'system' || m.pinned) protectedIdx.add(i);
});
if (n > 0) protectedIdx.add(0);
const firstTurn = messages.findIndex((m) => m.role !== 'system');
if (firstTurn !== -1) protectedIdx.add(firstTurn);
for (let i = Math.max(lastUser === -1 ? n - 1 : lastUser, 0); i < n; i++) protectedIdx.add(i);
const protectedTokens = [...protectedIdx].reduce((sum, i) => sum + messages[i].tokens, 0);
if (protectedTokens > opts.budgetTokens) {
warnings.push(
`Protected messages alone are ${protectedTokens.toLocaleString('en-US')} tokens against a ${opts.budgetTokens.toLocaleString('en-US')} budget — raise the budget (or reserve less for the reply) before pruning anything else.`,
);
}
// Fill the remaining budget newest-to-oldest through the middle.
const actions = new Array(n).fill('drop');
for (const i of protectedIdx) actions[i] = 'keep';
let used = protectedTokens;
for (let i = n - 1; i >= 0; i--) {
if (actions[i] !== 'drop') continue;
if (used + messages[i].tokens <= opts.budgetTokens) {
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.
const summarizeIdx = actions
.map((a, i) => (a === 'drop' && !protectedIdx.has(i) ? i : -1))
.filter((i) => i >= 0);
const summarizeTokens = summarizeIdx.reduce((sum, i) => sum + messages[i].tokens, 0);
const attemptedSummaryCost =
summarizeIdx.length > 0 ? SUMMARY_FIXED_TOKENS + Math.ceil(summarizeTokens * SUMMARY_RATIO) : 0;
// The summary only costs anything when it is actually applied — otherwise
// those turns drop and cost zero.
let summaryCost = 0;
if (attemptedSummaryCost > 0 && used + attemptedSummaryCost <= opts.budgetTokens) {
for (const i of summarizeIdx) actions[i] = 'summarize';
summaryCost = attemptedSummaryCost;
used += summaryCost;
} else if (attemptedSummaryCost > 0) {
warnings.push(
`Even the compressed summary (${attemptedSummaryCost.toLocaleString('en-US')} tokens) does not fit the remaining budget — the oldest turns are dropped instead.`,
);
}
const decisions = messages.map((m, i) => ({
index: i,
role: m.role,
action: actions[i],
tokens: m.tokens,
}));
let keptTokens = 0;
let droppedTokens = 0;
let foldedTokens = 0;
for (const d of decisions) {
if (d.action === 'keep') keptTokens += d.tokens;
else if (d.action === 'drop') droppedTokens += d.tokens;
else foldedTokens += d.tokens;
}
return {
decisions,
keptTokens,
summarizedTokens: foldedTokens,
droppedTokens,
summaryCostTokens: summaryCost,
projectedTokens: keptTokens + summaryCost,
fitsBudget: keptTokens + summaryCost <= opts.budgetTokens,
warnings,
};
}
/** Human-readable one-line summary of a plan. */
export function describePrune(plan) {
if (!plan.fitsBudget) {
return `Does not fit: ${plan.projectedTokens.toLocaleString('en-US')} tokens projected against the budget.`;
}
const parts = [`${plan.keptTokens.toLocaleString('en-US')} kept`];
if (plan.summarizedTokens > 0) {
parts.push(
`${plan.summarizedTokens.toLocaleString('en-US')} folded into a ${plan.summaryCostTokens.toLocaleString('en-US')}-token summary`,
);
}
if (plan.droppedTokens > 0) parts.push(`${plan.droppedTokens.toLocaleString('en-US')} dropped`);
return `${parts.join(' · ')} — 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 →