Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 41 additions & 50 deletions packages/core/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,46 +13,29 @@ const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_OUTPUT_TOKENS = 4_096
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Objective
- [one or two brief sentences describing what the user is trying to accomplish]

## Important Details
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]

## Work State
### Completed
- [finished work, verified facts, or changes made; otherwise "(none)"]

### Active
- [current work, partial changes, or investigation state; otherwise "(none)"]

### Blocked
- [blockers, failing commands, or unknowns; otherwise "(none)"]

## Next Move
1. [immediate concrete action, or "(none)"]
2. [next action if known, or "(none)"]

## Relevant Files
- [file or directory path: why it matters, or "(none)"]
</template>

Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
const SUMMARY_UPDATE_INSTRUCTIONS = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
const SUMMARY_TEMPLATE = `You are creating a comprehensive context restoration document. This document will serve as the foundation for continued work - it must preserve critical knowledge that would otherwise be lost.

Create a detailed summary with these sections:
1. Current Task State - what is being worked on, next steps, blockers
2. Resolved Code & Lessons Learned - working code verbatim, failed approaches, insights
3. User Directives - explicit preferences, style rules, things to always/never do
4. Custom Utilities & Commands - scripts, aliases, debugging commands
5. Design Decisions & Derived Requirements - architecture decisions, API contracts, patterns
6. Technical Facts - file paths, function names, config values, environment details

Critical rules:
- PRESERVE working code verbatim in fenced blocks
- INCLUDE failed approaches with explanations
- Be specific with paths, line numbers, function names
- Capture the "why" behind decisions
- User directives are sacred - never omit them`
const SUMMARY_UPDATE_INSTRUCTIONS = `The <prior-summary> summarizes everything that happened before the <extracted_context>. Merge all information from the <prior-summary> into your new document. Do not lose any historical context.

When combining:
- Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
- The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
- Add new progress, decisions, constraints, and context from the conversation.
- Move completed work from "Active" to "Completed".
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
- Update "Objective" and "Next Move" to reflect the current work state.`
- Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <extracted_context> does not mention them. Drop only what is finished and no longer needed.
- The <extracted_context> is more recent than the <prior-summary>. Where they conflict, the newer content wins: state the corrected fact and drop the old claim.
- Add new progress, decisions, constraints, and context from the <extracted_context>.`
const RECENT_GUIDANCE = `The <recent_context> below is the most recent activity in the session. It is NOT part of what you are summarizing - it stays in the conversation verbatim. Use it only to judge relevance: weight the summary toward what matters for where the session is currently headed, and drop detail from the <extracted_context> that is no longer relevant to the current direction.`

type Entry = {
readonly seq: number
Expand Down Expand Up @@ -157,20 +140,28 @@ const select = (
}
}

export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => {
const conversation = `Here is the conversation so far:\n\n<conversation>\n${input.context.join("\n\n")}\n</conversation>`
if (!input.previousSummary)
return [
conversation,
"Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.",
SUMMARY_TEMPLATE,
].join("\n\n")
export const buildPrompt = (input: {
readonly previousSummary?: string
readonly context: readonly string[]
readonly recent?: string
}) => {
const extracted = `The following conversation content needs to be distilled into the summary:\n\n<extracted_context>\n${input.context.join("\n\n")}\n</extracted_context>`
const recent = input.recent
? `${RECENT_GUIDANCE}\n\n<recent_context>\n${input.recent}\n</recent_context>`
: undefined
const prior = input.previousSummary
? `Here is the summary of everything before the <extracted_context>:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`
: undefined
return [
conversation,
`Here is the summary of the conversation before the <conversation> above:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
SUMMARY_UPDATE_INSTRUCTIONS,
SUMMARY_TEMPLATE,
].join("\n\n")
prior,
prior ? SUMMARY_UPDATE_INSTRUCTIONS : undefined,
extracted,
recent,
"Generate the context restoration document now.",
]
.filter(Boolean)
.join("\n\n")
}

export const make = (dependencies: Dependencies) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate
description: "Log level",
})

const Ratio = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))

export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
Expand Down Expand Up @@ -161,6 +163,22 @@ export const Info = Schema.Struct({
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
}),
recent_tokens: Schema.optional(NonNegativeInt).annotate({
description:
"Tokens of the most recent messages to include in the compaction prompt as a relevance signal, so the summary is weighted toward the session's current direction. These messages are also kept verbatim (they are within the preserved tail). 0 disables the signal.",
}),
trigger_ratio: Schema.optional(Ratio).annotate({
description:
"Fraction (0-1) of the model's context window at which automatic compaction triggers. When set, compaction fires once usage reaches trigger_ratio x context (a proactive percentage), overriding the default fixed-headroom (reserved) mechanism. Example: 0.85 compacts at 85% of the context window.",
}),
extract_ratio: Schema.optional(Ratio).annotate({
description:
"Fraction (0-1) of the current scoped conversation to summarize (the oldest portion). The rest is kept verbatim as the tail. When set, this overrides preserve_recent_tokens and scales with session size, so a small session is never fully summarized. Example: 0.40 summarizes the oldest 40% and keeps the newest 60% verbatim.",
}),
recent_ratio: Schema.optional(Ratio).annotate({
description:
"Fraction (0-1) of the current scoped conversation, taken from the newest end, to include in the compaction prompt as the relevance signal. When set, overrides recent_tokens and scales with session size. Example: 0.15 feeds the newest 15% to the summarizer as the lens.",
}),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
}),
Expand Down
58 changes: 56 additions & 2 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,16 @@ const layer = Layer.effect(
}) {
const limit = input.cfg.compaction?.tail_turns
if (limit !== undefined && limit <= 0) return { head: input.messages, tail_start_id: undefined }
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
// When extract_ratio is set, size the verbatim tail proportionally to the current
// conversation ((1 - extract_ratio) of scoped tokens) so it scales with session size and
// a small session is never fully summarized. Otherwise use the absolute token budget.
const extractRatio = input.cfg.compaction?.extract_ratio
const budget =
extractRatio === undefined
? preserveRecentBudget({ cfg: input.cfg, model: input.model })
: Math.floor(
(1 - extractRatio) * (yield* estimate({ messages: input.messages, model: input.model })),
)
const all = turns(input.messages)
if (!all.length) return { head: input.messages, tail_start_id: undefined }
const recent = limit === undefined ? all : all.slice(-limit)
Expand Down Expand Up @@ -268,6 +277,39 @@ const layer = Layer.effect(
}
})

// Walks the history newest-first and returns the index where the newest messages first
// exceed `budget` tokens, so the slice from there to the end fits within the budget.
const recentStart = Effect.fn("SessionCompaction.recentStart")(function* (input: {
history: SessionV1.WithParts[]
budget: number
model: Provider.Model
}) {
const sizes = yield* Effect.forEach(input.history, (message) =>
estimate({ messages: [message], model: input.model }),
)
const fit = sizes.reduceRight((state, size, index) => {
if (state.done) return state
const total = state.total + size
if (total > input.budget) return { total: state.total, start: index + 1, done: true }
return { total, start: index, done: false }
}, { total: 0, start: 0, done: false })
return fit.start
})

// The <recent_context> relevance signal: the newest messages up to `budget` tokens, serialized
// oldest-first. These stay verbatim in the conversation; they are shown to the summarizer only
// so it can weight the summary toward the session's current direction.
const recentContext = Effect.fn("SessionCompaction.recentContext")(function* (input: {
history: SessionV1.WithParts[]
budget: number
model: Provider.Model
}) {
if (input.budget <= 0) return undefined
const start = yield* recentStart(input)
const text = input.history.slice(start).map(serialize).filter(Boolean).join("\n\n")
return text || undefined
})

// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
Expand Down Expand Up @@ -364,8 +406,11 @@ const layer = Layer.effect(
const prior = completedCompactions(history)
const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
const previousSummary = prior.at(-1)?.summary
// The scoped conversation both ratios and selection reason about: history with prior
// compaction turns hidden.
const scoped = history.filter((_, index) => !hidden.has(index))
const selected = yield* select({
messages: history.filter((_, index) => !hidden.has(index)),
messages: scoped,
cfg,
model,
})
Expand All @@ -378,12 +423,21 @@ const layer = Layer.effect(
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
// recent_ratio (proportional) overrides the absolute recent_tokens when set. The result is a
// subset of the verbatim tail, fed to the summarizer as the relevance signal.
const recentRatio = cfg.compaction?.recent_ratio
const recentBudget =
recentRatio === undefined
? (cfg.compaction?.recent_tokens ?? 0)
: Math.floor(recentRatio * (yield* estimate({ messages: scoped, model })))
const recent = yield* recentContext({ history: scoped, budget: recentBudget, model })
const nextPrompt =
compacting.prompt ??
[
buildPrompt({
previousSummary,
context: [conversation],
recent,
}),
...compacting.context,
]
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/session/overflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@ export function isOverflow(input: {

const count =
input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
// When trigger_ratio is set, compact proactively at trigger_ratio x context (a percentage of the
// window) instead of the default fixed-headroom threshold.
const triggerRatio = input.cfg.compaction?.trigger_ratio
if (triggerRatio !== undefined) return count >= input.model.limit.context * triggerRatio
return count >= usable(input)
}
Loading