|
|
@@ -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
|
|
|
+ })
|
|
|
+}
|