Conversation Pruner — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure logic for the Conversation Pruner tool (slug: conversation-pruner).
// Given a conversation with per-message token counts and a context budget,
// compute a deterministic pruning plan: which messages to keep, which to
// fold into a running summary, and which to drop outright — protecting
// system messages, pinned turns, and the current (last user) request.
export type ChatRole = 'system' | 'user' | 'assistant' | 'tool';
export interface ConversationMessage {
role: ChatRole;
content: string;
/** Token count for this message (prompt-side framing included by caller). */
tokens: number;
/** Pinned messages are never dropped or summarized. */
pinned?: boolean;
}
export interface PruneOptions {
/** Total tokens available for the history (context window minus reserved reply). */
budgetTokens: number;
}
export type PruneAction = 'keep' | 'summarize' | 'drop';
export interface PruneDecision {
index: number;
role: ChatRole;
action: PruneAction;
tokens: number;
}
export interface PrunePlan {
decisions: PruneDecision[];
keptTokens: number;
summarizedTokens: number;
droppedTokens: number;
/** Tokens the summary placeholder itself will cost in the prompt. */
summaryCostTokens: number;
projectedTokens: number;
fitsBudget: boolean;
warnings: string[];
}
/** Summary compression model: fixed framing + 10% of the folded content. */
export const SUMMARY_FIXED_TOKENS = 60;
export const SUMMARY_RATIO = 0.1;
export function planPrune(
messages: readonly ConversationMessage[],
opts: PruneOptions,
): PrunePlan {
const warnings: string[] = [];
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: ChatRole) => {
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<number>();
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);
// (reduce seeded: an empty conversation has no protected indexes at all)
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: PruneAction[] = 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: PruneDecision[] = 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: PrunePlan): string {
if (!plan.fitsBudget) {
return `Does not fit: ${plan.projectedTokens.toLocaleString('en-US')} tokens projected against the budget.`;
}
const parts: string[] = [`${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 →