| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- export * as SessionCompaction from "./compaction"
- import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
- import { DateTime, Effect, Stream } from "effect"
- import type { Config } from "../config"
- import type { EventV2 } from "../event"
- import { SessionEvent } from "./event"
- import { SessionMessage } from "./message"
- import { SessionSchema } from "./schema"
- import { Token } from "../util/token"
- 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>
- ## Goal
- - [single-sentence task summary]
- ## Constraints & Preferences
- - [user constraints, preferences, specs, or "(none)"]
- ## Progress
- ### Done
- - [completed work or "(none)"]
- ### In Progress
- - [current work or "(none)"]
- ### Blocked
- - [blockers or "(none)"]
- ## Key Decisions
- - [decision and why, or "(none)"]
- ## Next Steps
- - [ordered next actions or "(none)"]
- ## Critical Context
- - [important technical facts, errors, open questions, 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, commands, error strings, and identifiers when known.
- - Do not mention the summary process or that context was compacted.`
- type Entry = {
- readonly seq: number
- readonly message: SessionMessage.Message
- }
- type Settings = {
- readonly auto: boolean
- readonly buffer: number
- readonly tokens: number
- }
- type Dependencies = {
- readonly events: EventV2.Interface
- readonly llm: {
- readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
- }
- readonly config: readonly Config.Entry[]
- }
- type Input = {
- readonly sessionID: SessionSchema.ID
- readonly entries: readonly Entry[]
- readonly model: Model
- readonly request: LLMRequest
- }
- const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
- const truncate = (value: string) =>
- value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
- export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
- content
- .map((item) =>
- item.type === "text" ? item.text : `[Attached ${item.mime}${item.name === undefined ? "" : `: ${item.name}`}]`,
- )
- .join("\n")
- const serialize = (message: SessionMessage.Message) => {
- if (message.type === "user") {
- const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
- return [`[User]: ${message.text}`, ...files].join("\n")
- }
- if (message.type === "assistant") {
- return message.content
- .flatMap((part) => {
- if (part.type === "text") return [`[Assistant]: ${part.text}`]
- if (part.type === "reasoning") return part.text ? [`[Assistant reasoning]: ${part.text}`] : []
- const input = typeof part.state.input === "string" ? part.state.input : JSON.stringify(part.state.input)
- if (part.state.status === "completed")
- return [
- `[Assistant tool call]: ${part.name}(${input})`,
- `[Tool result]: ${truncate(serializeToolContent(part.state.content))}`,
- ]
- if (part.state.status === "error")
- return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]
- return [`[Assistant tool call]: ${part.name}(${input})`]
- })
- .join("\n")
- }
- if (message.type === "system") return `[System update]: ${message.text}`
- if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
- if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
- return ""
- }
- const settings = (documents: readonly Config.Entry[]) => {
- const configured = documents
- .filter((entry): entry is Config.Document => entry.type === "document")
- .flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
- return configured.reduce<Settings>(
- (result, current) => ({
- auto: current.auto ?? result.auto,
- buffer: current.buffer ?? result.buffer,
- tokens: current.keep?.tokens ?? result.tokens,
- }),
- { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
- )
- }
- const select = (
- entries: readonly Entry[],
- tokens: number,
- ): { readonly head: string; readonly recent: string } | undefined => {
- const conversation = entries
- .filter((entry) => entry.message.type !== "compaction")
- .map((entry) => serialize(entry.message))
- .filter(Boolean)
- if (conversation.length === 0) return
- let total = 0
- let split = conversation.length
- let splitPrefix = ""
- let splitSuffix = ""
- for (let index = conversation.length - 1; index >= 0; index--) {
- const next = total + Token.estimate(conversation[index])
- if (next > tokens) {
- const remaining = Math.max(0, tokens - total) * 4
- if (remaining > 0) {
- splitPrefix = conversation[index].slice(0, -remaining)
- splitSuffix = conversation[index].slice(-remaining)
- split = index + 1
- }
- break
- }
- total = next
- split = index
- }
- return {
- head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
- recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
- }
- }
- export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
- [
- input.previousSummary
- ? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
- : "Create a new anchored summary from the conversation history.",
- SUMMARY_TEMPLATE,
- ...input.context,
- ].join("\n\n")
- export const make = (dependencies: Dependencies) => {
- const config = settings(dependencies.config)
- const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
- const context = input.model.route.defaults.limits?.context
- if (context === undefined || context <= 0) return false
- const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
- const selected = select(input.entries, config.tokens)
- const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
- if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
- const summaryPrompt = buildPrompt({
- previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
- context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
- })
- const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
- if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
- const messageID = SessionMessage.ID.create()
- yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
- sessionID: input.sessionID,
- messageID,
- timestamp: yield* DateTime.now,
- reason: "auto",
- })
- const chunks: string[] = []
- let failed = false
- const summarized = yield* dependencies.llm
- .stream(
- LLM.request({
- model: input.model,
- messages: [Message.user(summaryPrompt)],
- tools: [],
- generation: { maxTokens: summaryOutput },
- }),
- )
- .pipe(
- Stream.runForEach((event) => {
- if (LLMEvent.is.providerError(event)) failed = true
- if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
- return Effect.void
- }),
- Effect.as(true),
- Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
- )
- const summary = chunks.join("")
- if (!summarized || failed || !summary.trim()) return false
- yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
- sessionID: input.sessionID,
- messageID,
- timestamp: yield* DateTime.now,
- reason: "auto",
- text: summary,
- recent: selected.recent,
- })
- return true
- })
- const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) {
- if (!config.auto) return false
- const context = input.model.route.defaults.limits?.context
- if (context === undefined || context <= 0) return false
- const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
- if (
- estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
- context - Math.max(output, config.buffer)
- )
- return false
- return yield* compactAfterOverflow(input)
- })
- return {
- compactIfNeeded,
- compactAfterOverflow,
- }
- }
|