Skip to content

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

// Package conversationpruner is the Go twin of CosmoDev's
// src/lib/conversationPruner.ts (dual source: the web lib is TypeScript, the
// CLI lib is Go — kept in lock-step). Pure + deterministic, never panics. The
// table-driven tests in conversationpruner_test.go share vectors with
// src/lib/conversationPruner.test.ts so the two implementations are held to
// the same contract.
//
// Given a conversation with per-message token counts and a context budget,
// PlanPrune computes 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.
package conversationpruner

import (
	"fmt"
	"strconv"
	"strings"
)

// ChatRole mirrors the TS ChatRole union ('system' | 'user' | 'assistant' | 'tool').
type ChatRole string

const (
	RoleSystem    ChatRole = "system"
	RoleUser      ChatRole = "user"
	RoleAssistant ChatRole = "assistant"
	RoleTool      ChatRole = "tool"
)

// ConversationMessage mirrors the TS ConversationMessage interface.
type ConversationMessage struct {
	Role ChatRole
	// Content is the message body (never inspected by the planner).
	Content string
	// Tokens is the token count for this message (prompt-side framing
	// included by the caller).
	Tokens int
	// Pinned messages are never dropped or summarized. (TS `pinned?: boolean`
	// → zero value false = unpinned.)
	Pinned bool
}

// Options mirrors the TS PruneOptions interface; the zero value
// (Options{BudgetTokens: 0}) is valid, like every other non-negative budget.
type Options struct {
	// BudgetTokens is the total tokens available for the history (context
	// window minus reserved reply). Negative values are rejected, mirroring
	// the TS RangeError.
	BudgetTokens int
}

// PruneAction mirrors the TS PruneAction union.
type PruneAction string

const (
	ActionKeep      PruneAction = "keep"
	ActionSummarize PruneAction = "summarize"
	ActionDrop      PruneAction = "drop"
)

// PruneDecision mirrors the TS PruneDecision interface.
type PruneDecision struct {
	Index  int
	Role   ChatRole
	Action PruneAction
	Tokens int
}

// PrunePlan mirrors the TS PrunePlan interface.
type PrunePlan struct {
	Decisions        []PruneDecision
	KeptTokens       int
	SummarizedTokens int
	DroppedTokens    int
	// SummaryCostTokens is what the summary placeholder itself will cost in
	// the prompt. An unapplied (rejected) summary costs zero.
	SummaryCostTokens int
	ProjectedTokens   int
	FitsBudget        bool
	Warnings          []string
}

// Summary compression model: fixed framing + 10% of the folded content.
// Mirrors SUMMARY_FIXED_TOKENS / SUMMARY_RATIO in the TS lib.
const (
	SummaryFixedTokens = 60
	SummaryRatio       = 0.1
)

// PlanPrune computes the pruning plan for a conversation. It is the Go twin of
// planPrune() in src/lib/conversationPruner.ts and must agree with it on every
// shared vector. Where the TS lib throws a RangeError (negative budget or
// negative per-message tokens), the Go twin returns an error and a zero-value
// plan; it never panics.
func PlanPrune(messages []ConversationMessage, opts Options) (PrunePlan, error) {
	if opts.BudgetTokens < 0 {
		return PrunePlan{}, fmt.Errorf("budgetTokens must be >= 0")
	}
	for _, msg := range messages {
		if msg.Tokens < 0 {
			return PrunePlan{}, fmt.Errorf("message tokens must be >= 0")
		}
	}

	warnings := []string{}
	n := len(messages)

	lastUser := -1
	for i := n - 1; i >= 0; i-- {
		if messages[i].Role == RoleUser {
			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 := make([]bool, n)
	for i, msg := range messages {
		if msg.Role == RoleSystem || msg.Pinned {
			protected[i] = true
		}
	}
	if n > 0 {
		protected[0] = true
	}
	for i, msg := range messages {
		if msg.Role != RoleSystem {
			protected[i] = true
			break
		}
	}
	start := n - 1 // no user message at all → only the final index anchors the tail
	if lastUser != -1 {
		start = lastUser
	}
	if start < 0 {
		start = 0
	}
	for i := start; i < n; i++ {
		protected[i] = true
	}

	// (reduce seeded: an empty conversation has no protected indexes at all)
	protectedTokens := 0
	for i, p := range protected {
		if p {
			protectedTokens += messages[i].Tokens
		}
	}
	if protectedTokens > opts.BudgetTokens {
		warnings = append(warnings, fmt.Sprintf(
			"Protected messages alone are %s tokens against a %s budget — raise the budget (or reserve less for the reply) before pruning anything else.",
			comma(protectedTokens), comma(opts.BudgetTokens)))
	}

	// Fill the remaining budget newest-to-oldest through the middle.
	actions := make([]PruneAction, n)
	for i := range actions {
		actions[i] = ActionDrop
	}
	for i, p := range protected {
		if p {
			actions[i] = ActionKeep
		}
	}
	used := protectedTokens
	for i := n - 1; i >= 0; i-- {
		if actions[i] != ActionDrop {
			continue
		}
		if used+messages[i].Tokens <= opts.BudgetTokens {
			actions[i] = ActionKeep
			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.
	var summarizeIdx []int
	for i, a := range actions {
		if a == ActionDrop && !protected[i] {
			summarizeIdx = append(summarizeIdx, i)
		}
	}
	summarizeTokens := 0
	for _, i := range summarizeIdx {
		summarizeTokens += messages[i].Tokens
	}
	attemptedSummaryCost := 0
	if len(summarizeIdx) > 0 {
		attemptedSummaryCost = SummaryFixedTokens + ceilSummaryShare(summarizeTokens)
	}

	// The summary only costs anything when it is actually applied — otherwise
	// those turns drop and cost zero.
	summaryCost := 0
	if attemptedSummaryCost > 0 && used+attemptedSummaryCost <= opts.BudgetTokens {
		for _, i := range summarizeIdx {
			actions[i] = ActionSummarize
		}
		summaryCost = attemptedSummaryCost
		used += summaryCost
	} else if attemptedSummaryCost > 0 {
		warnings = append(warnings, fmt.Sprintf(
			"Even the compressed summary (%s tokens) does not fit the remaining budget — the oldest turns are dropped instead.",
			comma(attemptedSummaryCost)))
	}

	decisions := make([]PruneDecision, 0, n)
	for i, msg := range messages {
		decisions = append(decisions, PruneDecision{
			Index:  i,
			Role:   msg.Role,
			Action: actions[i],
			Tokens: msg.Tokens,
		})
	}

	var keptTokens, droppedTokens, foldedTokens int
	for _, d := range decisions {
		switch d.Action {
		case ActionKeep:
			keptTokens += d.Tokens
		case ActionDrop:
			droppedTokens += d.Tokens
		default: // ActionSummarize
			foldedTokens += d.Tokens
		}
	}

	return PrunePlan{
		Decisions:         decisions,
		KeptTokens:        keptTokens,
		SummarizedTokens:  foldedTokens,
		DroppedTokens:     droppedTokens,
		SummaryCostTokens: summaryCost,
		ProjectedTokens:   keptTokens + summaryCost,
		FitsBudget:        keptTokens+summaryCost <= opts.BudgetTokens,
		Warnings:          warnings,
	}, nil
}

// DescribePrune renders a human-readable one-line summary of a plan. It is the
// Go twin of describePrune() in src/lib/conversationPruner.ts and must agree
// with it on every shared vector.
func DescribePrune(plan PrunePlan) string {
	if !plan.FitsBudget {
		return fmt.Sprintf(
			"Does not fit: %s tokens projected against the budget.",
			comma(plan.ProjectedTokens))
	}
	parts := []string{fmt.Sprintf("%s kept", comma(plan.KeptTokens))}
	if plan.SummarizedTokens > 0 {
		parts = append(parts, fmt.Sprintf(
			"%s folded into a %s-token summary",
			comma(plan.SummarizedTokens), comma(plan.SummaryCostTokens)))
	}
	if plan.DroppedTokens > 0 {
		parts = append(parts, fmt.Sprintf("%s dropped", comma(plan.DroppedTokens)))
	}
	return strings.Join(parts, " · ") + " — fits the budget."
}

// ceilSummaryShare returns ceil(tokens * SummaryRatio) — the variable share of
// the summary cost — computed in exact integer arithmetic. SummaryRatio is 0.1,
// and (t+9)/10 == ceil(t/10) == the TS Math.ceil(t * 0.1) for non-negative
// integers, avoiding float rounding surprises at multiples of 10.
func ceilSummaryShare(tokens int) int {
	if tokens <= 0 {
		return 0
	}
	return (tokens + 9) / 10
}

// comma formats n with en-US thousands separators, mirroring the TS lib's
// toLocaleString('en-US') in warnings and descriptions.
func comma(n int) string {
	neg := n < 0
	digits := strconv.Itoa(n)
	if neg {
		digits = digits[1:]
	}
	var groups []string
	for len(digits) > 3 {
		groups = append([]string{digits[len(digits)-3:]}, groups...)
		digits = digits[:len(digits)-3]
	}
	groups = append([]string{digits}, groups...)
	s := strings.Join(groups, ",")
	if neg {
		return "-" + s
	}
	return s
}

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 →