Skip to content
Merged
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
1 change: 0 additions & 1 deletion packages/core/src/config/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Schema } from "effect"
import { NonNegativeInt } from "../schema"

export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
turns: NonNegativeInt.pipe(Schema.optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}

Expand Down
224 changes: 224 additions & 0 deletions packages/core/src/session/compaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
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[]
}

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]`

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(JSON.stringify(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)
return Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly entries: readonly Entry[]
readonly model: Model
readonly request: LLMRequest
}) {
const context = input.model.route.defaults.limits?.context
if (!config.auto || 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

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[] = []
yield* dependencies.llm
.stream(
LLM.request({
model: input.model,
messages: [Message.user(summaryPrompt)],
tools: [],
generation: { maxTokens: summaryOutput },
}),
)
.pipe(
Stream.runForEach((event) => {
if (!LLMEvent.is.textDelta(event)) return Effect.void
chunks.push(event.text)
return Effect.void
}),
)
const summary = chunks.join("")
if (!summary.trim()) return yield* Effect.die("Compaction returned an empty summary")
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: "auto",
text: summary,
recent: selected.recent,
})
return true
})
}
20 changes: 16 additions & 4 deletions packages/core/src/session/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,15 +435,16 @@ export namespace Compaction {

export const Delta = EventV2.define({
type: "session.next.compaction.delta",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type

export const Ended = EventV2.define({
// Retain the unpublished v1 decoder so stored beta events remain replayable.
export const EndedV1 = EventV2.define({
type: "session.next.compaction.ended",
...options,
schema: {
Expand All @@ -452,6 +453,18 @@ export namespace Compaction {
include: Schema.String.pipe(Schema.optional),
},
})

export const Ended = EventV2.define({
type: "session.next.compaction.ended",
sync: { aggregate: "sessionID", version: 2 },
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
},
})
export type Ended = typeof Ended.Type
}

Expand Down Expand Up @@ -482,10 +495,9 @@ const DurableDefinitions = [
Reasoning.Ended,
Retried,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const

export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type DurableEvent = typeof Durable.Type
Expand Down
19 changes: 14 additions & 5 deletions packages/core/src/session/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const decode = Schema.decodeUnknownEffect(SessionMessage.Message)

const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq))
Expand All @@ -27,7 +27,7 @@ const messageRows = Effect.fnUntraced(function* (
compaction: { readonly seq: number } | undefined,
baselineSeq?: number,
) {
return yield* db
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
Expand All @@ -49,6 +49,7 @@ const messageRows = Effect.fnUntraced(function* (
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return rows
})

const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
Expand Down Expand Up @@ -83,9 +84,17 @@ export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function*
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
decodeMessageRow,
return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message)
})

export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq)
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
})

Expand Down
Loading
Loading