Memory

Conversation compression

This page covers the history cascade — one of four context-management mechanisms that keep a long-running conversation inside a model-readable size. The cascade is a four-layer pipeline that runs on every turn to shrink the message buffer while preserving every decision, fact, name, number, and constraint that matters. The layers fire only when the buffer is still over budget after the previous one; cheap layers run first, and a model pass is a last resort. The other three mechanisms — structured replay, working-set trimming, and a whole-history digest — are introduced further down this page.

Updated today

Overview

At a glance
Module
src/lib/skills/memory.ts
Entry point
cascadeCompress(messages, options)
Layers
4, cascading, each only fires if still over budget
Token budget
Scales with the tier in use, not a fixed number
Tool result threshold
1,500 characters
Recent messages kept
10 by default
Summary group size
6 messages
Model compression tier
Allegro

Long conversations break models. Once the buffer exceeds the model's context window the request fails outright; well before that, quality degrades as the model spends attention on stale content. The cascade exists so the chat handler can hand the model a clean, compact buffer on every turn, regardless of how long the underlying conversation has been running.

The order is intentional. The cheapest layer that fixes the problem runs first. If the buffer is bloated by a single huge tool result, layer 1 fixes it without touching anything else. If the buffer is long but mostly recent, layer 2 fixes it by dropping old messages. Only when the conversation is genuinely too rich to keep in full does the cascade reach layer 4 and spend a model call to write a structured summary.

Four mechanisms, not one

The history cascade documented on this page is one of four context-management mechanisms running today. They attack different shapes of the same problem.

MechanismWhat it doesWhen it runs
History cascade (this page)Progressively trims and summarizes older turns, cheapest step first, with a model pass only as a last resort. Recent, explicit instructions are always kept verbatim.On a turn, only as needed
Structured replayAn append-only, faithful reconstruction of exactly what happened in a conversation, including tool calls and their results, with the stable parts of the prompt kept byte-identical turn to turn so repeated context is cheap to reprocess.Not on for every conversation type
Working-set trimmingKeeps the most recent chunk of tool output in full detail and distills everything older into a one-line outcome. The detail is recoverable — the model can re-fetch it if it actually needs it again.Automatically, every tool round
Whole-history digestA hard ceiling backstop: if a conversation still risks overflowing, the oldest middle portion collapses to one line per turn while the very first and most recent turns stay verbatim.Only when a conversation risks overflow; also powers the manual "compact this conversation" action
Note
These four mechanisms are complementary, not competing implementations of the same idea — a given turn can be shaped by more than one of them. The sections below go deep on the history cascade first, since it's the one that runs on the widest range of conversations, then introduce the other three in the same functional terms used here.

Why a cascade

One pipeline, four stages, picked to attack different causes of bloat.

Cause of bloatLayer that fixes itCost
A single tool result returned tens of thousands of charactersLayer 1, truncate by lengthA handful of substring slices
The conversation is 100 turns long but each turn is smallLayer 2, drop the oldestArray splice
A genuinely long thread with no obvious trim victimsLayer 3, rule-based groupsString concatenation, no model call
A long thread where every message carries decisions worth preservingLayer 4, model compressionOne model request, capped at 15 seconds
Note
Each layer is idempotent. Running the cascade twice on the same buffer is safe; a layer that already trimmed everything it could is a no-op on the second pass.

Entry point

One async function. Four options. Four return values.

src/lib/skills/memory.tsts
210export async function cascadeCompress(
211 messages: Message[],
212 options?: CascadeCompressionOptions,
213): Promise<CascadeCompressionResult> {
214 const maxBudget = options?.maxTokenBudget ?? 100_000
215 const maxToolChars = options?.maxToolResultChars ?? 1500
216 const recentKeep = options?.recentMessagesToKeep ?? 10
217 const groupSize = options?.summaryGroupSize ?? 6
218 const layersApplied: number[] = []
219 let working = messages.map((m) => ({ ...m })) // shallow clone
220
221 // ...four cascading layers, each guarded by a totalTokens(working) > maxBudget check
222}

The function takes a shallow clone of the input so it never mutates the caller's array. The layersApplied array tracks which layers ran for the current call; the return value exposes it so the caller can log compression behaviour without re-deriving it. The maxTokenBudget shown here is a default the caller can override — in practice the chat handler passes a budget appropriate to the tier serving the conversation, which is why the effective ceiling scales with the tier in use rather than being one fixed number in production.

Token budget

The gate that controls whether a layer fires — sized to the tier in use, not a fixed constant.

src/lib/skills/memory.tsts
1// Layer N executes only when the buffer is still over budget after layer N-1.
2// if (totalTokens(working) > maxBudget) { ...apply layer... }
3// totalTokens() approximates token count from character length; the goal is
4// not exactness but a stable monotonic estimate that decisions can be made on.
Tip
A token is roughly four characters in English. totalTokens is a fast approximation, not a tokenizer. The ceiling itself scales with the context size of whichever tier is serving the conversation — a request on a larger-context tier gets a larger runway before layers start firing, rather than every conversation being held to the same fixed number. Skill prompts that need a strict bound for their own purposes should rely on the model's own response budget, not on this estimate.

Layer 1: Tool result truncation

The cheapest fix. Most context blow-ups come from one runaway tool result.

src/lib/skills/memory.tsts
226if (totalTokens(working) > maxBudget) {
227 layersApplied.push(1)
228 for (const m of working) {
229 if (m.content.length > maxToolChars) {
230 // Heuristic: very long assistant messages containing JSON or fenced
231 // code, or simply over a hard length, are likely raw tool results.
232 const isLikelyToolResult =
233 m.role === "assistant" &&
234 (m.content.startsWith("{") ||
235 m.content.startsWith("[") ||
236 m.content.includes("\`\`\`") ||
237 m.content.length > 4000)
238 if (isLikelyToolResult) {
239 m.content = m.content.slice(0, maxToolChars) +
240 "\n...[truncated by context manager]"
241 }
242 }
243 }
244}

Tool results are the biggest individual contributors to context bloat. A scrape of a content-heavy page can return tens of thousands of characters; a list of leads from a CRM can be hundreds of kilobytes; a generated document can dwarf the rest of the conversation by an order of magnitude. Layer 1 catches those by spotting messages that look like raw tool output (assistant role, starts with a JSON token or a code fence, or simply over 4,000 characters) and clipping them to the configured threshold.

The truncation appends an explicit marker so the model knows the content was cut. This matters: a silently truncated tool result reads as if the tool returned exactly that much, which can mislead the next turn into incorrect assumptions about completeness.

Warning
A skill that needs to read a full tool result must do so within the same turn it called the tool. Subsequent turns see the truncated version. If a skill needs the entire result later, it should summarise it explicitly and save the summary to memory before the next turn.

Layer 2: Drop oldest

The blunt fix when the buffer is long but truncation did not get us there.

src/lib/skills/memory.tsts
249if (totalTokens(working) > maxBudget && working.length > recentKeep) {
250 layersApplied.push(2)
251 const dropped = working.splice(0, working.length - recentKeep)
252 console.log(`[cascade] Layer 2 dropped ${dropped.length} messages`)
253}

Layer 2 keeps the most recent recentKeep messages and drops everything before them. The default is 10. The assumption is that the most recent interactions carry the most contextual weight for the next turn; older content can be reconstructed through retrieval against persistent memory or, if needed, by reading the chat history directly.

The drop is lossy and intentional. Anything that mattered from the dropped range should have been written to persistent memory by an earlier turn. Skills designed for long sessions write memory aggressively for this reason.

Layer 3: Rule-based grouping

When neither truncation nor dropping is enough, replace older groups with one-line summaries.

src/lib/skills/memory.tsts
258if (totalTokens(working) > maxBudget && working.length > 4) {
259 layersApplied.push(3)
260 const protectedCount = Math.min(4, working.length)
261 const compressible = working.slice(0, working.length - protectedCount)
262 const protectedMsgs = working.slice(working.length - protectedCount)
263
264 const summaries: Message[] = []
265 for (let i = 0; i < compressible.length; i += groupSize) {
266 const group = compressible.slice(i, i + groupSize)
267 const bullets = group.map((m) => {
268 const label = m.role === "user" ? "User" : "Assistant"
269 const snippet = m.content.slice(0, 100).replace(/\n/g, " ")
270 return `- ${label}: ${snippet}${m.content.length > 100 ? "..." : ""}`
271 })
272 summaries.push({
273 role: "assistant",
274 content: `[Summarized ${group.length} messages]\n${bullets.join("\n")}`,
275 })
276 }
277 working = [...summaries, ...protectedMsgs]
278}

Layer 3 protects the last four messages, then iterates over the rest in chunks of groupSize (default 6). Each chunk becomes a single synthetic assistant message that lists the role and the first hundred characters of each member. The result keeps the timeline visible (the order of the bullets reflects the original order of the messages) without paying for full content storage.

No model call is involved. The summaries are mechanical and deterministic. That is the point: layer 3 has to be cheap enough to run frequently without affecting turn latency or cost.

Tip
The protected count of four is the floor that keeps the most recent exchange intact. After layer 3, the buffer is approximately [summary blocks of older content] + [last four messages verbatim].

Layer 4: Model compression

The last resort. Hand the remaining buffer to a fast model and ask for a dense summary.

src/lib/skills/memory.tsts
287if (totalTokens(working) > maxBudget) {
288 layersApplied.push(4)
289 try {
290 const { compressed, recentMessages } = await compressConversation(working, {
291 maxRecentMessages: Math.min(recentKeep, working.length),
292 })
293 if (compressed.compressedCount > 0) {
294 compressedContext = compressed.content
295 working = recentMessages
296 }
297 } catch (err) {
298 console.warn("[cascade] Layer 4 failed:", err)
299 // last resort if the model call itself fails
300 if (working.length > 4) working = working.slice(-4)
301 }
302}

Layer 4 calls compressConversation, which splits the buffer into recent messages to keep verbatim and older messages to compress, then sends the older portion to a model on the Allegro tier with a structured system prompt. The model produces a bullet list of decisions, facts, names, numbers, and constraints. The result replaces the older portion of the buffer and the recent messages stay attached.

If the compression call fails for any reason, the cascade does not abort. It falls back to keeping only the last four messages, which guarantees a buffer the next turn can read even in degraded conditions.

compressConversation

The function layer 4 calls. Also usable standalone.

src/lib/skills/memory.tsts
63export async function compressConversation(
64 messages: Message[],
65 options?: { maxRecentMessages?: number; model?: string },
66): Promise<{ compressed: CompressedMessage; recentMessages: Message[] }> {
67 const maxRecent = options?.maxRecentMessages ?? DEFAULT_RECENT_MESSAGES // 6
68 const model = options?.model ?? COMPRESSION_TX.bodyModel // resolves to the Allegro tier
69
70 if (messages.length <= maxRecent) {
71 return {
72 compressed: { role: "system", content: "(No prior context.)", compressedCount: 0, compressedAt: new Date().toISOString() },
73 recentMessages: messages,
74 }
75 }
76 // Split, format, call the compression tier with the compression system prompt, return.
77}
DefaultValue
Compression tierAllegro
DEFAULT_RECENT_MESSAGES6 (kept verbatim, the rest is compressed)
Per-message truncation before sending to the compression tier2,000 chars
Compression max_tokens1,024
API timeout15,000 ms (AbortSignal)
Fallback if the tier's credentials are unavailableReturns a placeholder system message; no compression performed

The compression prompt

The system prompt the model compressor runs under.

src/lib/skills/memory.tstext
1You are a conversation compression engine. Your job is to distill a
2conversation history into the most compact, information-dense summary possible.
3
4RULES:
5- Keep ALL decisions and agreements made
6- Keep ALL specific facts, numbers, names, dates, and identifiers
7- Keep ALL user preferences, constraints, and requirements
8- Keep the current state of any ongoing work or tasks
9- Drop pleasantries, greetings, and social niceties
10- Drop repetitive exploration that led to dead ends (just note "explored X - didn't work")
11- Drop filler words and verbose explanations
12- Format as concise bullet points grouped by topic
13- Use short, direct language

The prompt is the contract between the cascade and the model. The model is told to retain every load-bearing fact and to drop everything that is decoration. The output is forced into bullet points so the downstream turn sees a structured block that fits comfortably above the recent messages.

Result shape

What the caller gets back.

src/lib/skills/memory.tsts
1interface CascadeCompressionResult {
2 messages: Message[] // the compressed buffer ready to hand to the model
3 compressedContext: string // free-form prose summary (set when layer 4 fired)
4 layersApplied: number[] // e.g. [1, 2] when layers 1 and 2 fired
5 estimatedTokens: number // post-cascade token estimate
6}
Note
The compressedContext field is empty when only layers 1 through 3 fired. The buffer alone is enough to deliver the next turn. The field is populated only when layer 4 produced a free-form summary the next turn should consider as background.

Failure modes

The cascade is engineered to degrade gracefully. Here is how each layer fails.

FailureWhat happens
totalTokens estimate is wrongA layer may fire unnecessarily or skip when it should run. Buffer is still readable; the next turn's response may be slightly trimmed or slightly noisy.
A tool result was structured but not detected by the layer 1 heuristicThe result is kept verbatim. Later layers still compress around it.
recentKeep is set higher than the buffer lengthLayer 2 is a no-op. The cascade proceeds.
The compression tier times out at layer 4The buffer is hard-trimmed to the last 4 messages. The next turn still gets readable context but loses the older summary.
The compression tier's credentials are unavailable in productioncompressConversation returns a placeholder system message. Layer 4 is effectively disabled; the buffer flows through unchanged after layer 3.
Compression returns malformed textThe bullet output goes through unchecked. The model on the next turn handles imperfect input; it does not crash the cascade.

Structured replay

An append-only reconstruction of what actually happened, kept cheap to reprocess turn over turn.

Structured replay is an append-only, faithful reconstruction of exactly what happened in a conversation — including tool calls and their results — rather than a paraphrased summary. The stable parts of the prompt are kept byte-identical from one turn to the next, so a long conversation's shared prefix is cheap to reprocess instead of being rebuilt from scratch every turn.

Structured replay is not on for every conversation type. Where the history cascade documented above is the general-purpose mechanism, structured replay is a more specific reconstruction used where a faithful, tool-call-accurate record of the conversation matters more than a compact summary of it.

Working-set trimming

Runs automatically, every tool round, independent of the cascade above.

Working-set trimming keeps the most recent chunk of tool output in full detail and distills everything older into a one-line outcome. Unlike the cascade's layer 1 truncation, which only fires once the whole buffer is over budget, working-set trimming runs automatically on every tool round regardless of overall buffer size — it is a standing discipline on tool output specifically, not a budget-triggered layer.

The distillation is recoverable: if the model actually needs the detail from an older tool result again, it can re-fetch it rather than being permanently cut off from it, which is a different trade-off than the cascade's layer 1 truncation described above.

Whole-history digest

A hard ceiling backstop, and the same mechanism behind the manual "compact this conversation" action.

The whole-history digest is a hard ceiling backstop: if a conversation still risks overflowing after everything else, the oldest middle portion of the conversation collapses to one line per turn, while the very first turn and the most recent turns stay verbatim. It exists for the case none of the other mechanisms are built to fully absorb — a conversation that is long enough, and dense enough throughout, that even cascading compression and working-set trimming leave it near the ceiling.

The same mechanism powers the manual "compact this conversation" action available to a user — triggering it runs the identical whole-history collapse on demand rather than waiting for the ceiling to be reached automatically.

Tuning the cascade

Defaults are good for most chat sessions. Skills that need different behaviour pass options.

OptionDefaultWhen to change
maxTokenBudgetScales with the active tier's context sizeLower for budget-constrained skills, higher for skills running on a tier with a bigger context window
maxToolResultChars1,500Raise when working with tools whose useful payload is naturally large; lower for cheaper retention
recentMessagesToKeep10Higher for skills that need long verbatim recall; lower for tight loops with mostly summary state
summaryGroupSize6Smaller groups give finer-grained summaries at layer 3; larger groups compress more aggressively
Tip
Most skills do not pass options. The defaults are tuned for the typical chat session. Override sparingly and document why; an unusual override quickly becomes load-bearing.

File map

src/lib/skills
memory.tscascadeCompress, compressConversation, COMPRESSION_SYSTEM_PROMPT
types.tsMessage, CompressedMessage, CascadeCompressionResult
src/app/api/chat
v2/route.tscalls cascadeCompress before every turn

Glossary

History cascade
The four-layer compression pipeline documented on this page. Each layer only fires if the buffer is still over budget after the previous one. One of four context-management mechanisms in use today.
Layer 1
Truncation of oversized tool results above the per-message character threshold.
Layer 2
Hard drop of the oldest messages, keeping only the configured number of recent ones.
Layer 3
Rule-based grouping that replaces older message groups with synthesised assistant messages listing the role and first 100 characters of each.
Layer 4
Full model compression that sends the remaining buffer to a fast model with a structured system prompt and returns a bullet summary.
Protected count
The number of most recent messages layer 3 refuses to compress. Four by default.
Token budget
The maximum estimated token count the buffer is allowed to reach before layers start firing. Scales with the tier in use rather than a single fixed number.
Compressed context
The free-form summary string layer 4 produces. Returned alongside the compressed buffer for the next turn to consume.
Layers applied
An array of layer numbers that fired during the current call. Returned so callers can log compression behaviour.
Structured replay
An append-only, faithful reconstruction of a conversation including tool calls and results, with stable prompt portions kept byte-identical turn to turn. Not on for every conversation type.
Working-set trimming
Keeps the most recent chunk of tool output in full detail and distills older output into a one-line outcome, recoverable on demand. Runs automatically every tool round.
Whole-history digest
A hard ceiling backstop that collapses the oldest middle portion of a conversation to one line per turn while the first and most recent turns stay verbatim. Also powers the manual "compact this conversation" action.