Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8f362cd
improve compaction: inject context status, disable pruning, quality-f…
charles-cooper Dec 14, 2025
df9d01f
fix: make compaction continuation brief to avoid status reports
charles-cooper Dec 14, 2025
88878fd
add guidance: never ask user about compacting, just do it
charles-cooper Dec 14, 2025
cc31b5c
add opencodetmp to gitignore
charles-cooper Dec 14, 2025
03c6458
fix: terse continuation prompt, remind to write agent-files before co…
charles-cooper Dec 14, 2025
16b4d99
docs: optimize compaction prompts for detailed summary and focused co…
charles-cooper Dec 14, 2025
d64228f
fix: show 'Optimizing context' for manual compaction instead of 'Auto…
charles-cooper Dec 14, 2025
417d335
docs: emphasize updating .agent-files after completing tasks in syste…
charles-cooper Dec 14, 2025
5fff9da
docs: add files guidance to compaction prompt
charles-cooper Dec 14, 2025
888ae2d
feat: compaction writes .agent-files automatically, remove hidden tex…
charles-cooper Dec 14, 2025
cc77d00
docs: add concrete compaction triggers and effectiveness framing to s…
charles-cooper Dec 14, 2025
1d3d0ae
docs: add end-of-prompt checklist for memory updates
charles-cooper Dec 14, 2025
d6f1239
docs: emphasize compaction must never lose critical details
charles-cooper Dec 14, 2025
a14d574
docs: frame compaction preservation in terms of model effectiveness
charles-cooper Dec 14, 2025
28ef24e
fix: make compaction files array optional with default
charles-cooper Dec 14, 2025
5140288
docs: rewrite turn-ending checklist with active question framing
charles-cooper Dec 14, 2025
8017f68
docs: add explicit file list to turn-ending checklist
charles-cooper Dec 14, 2025
be87ba8
feat: add /debug-compaction command to show summary and handoff
charles-cooper Dec 14, 2025
dde7b11
docs: improve compaction heuristic with system prompt signal and size…
charles-cooper Dec 14, 2025
3ae6357
add system injection nudges for compaction and memory files
charles-cooper Dec 14, 2025
45e9be3
add .agent-files/ update reminders to all compaction nudges
charles-cooper Dec 14, 2025
e24fa6c
move self-check outside context-status tag
charles-cooper Dec 14, 2025
571822c
separate context-status, agent-files nudge, and self-check into disti…
charles-cooper Dec 14, 2025
25e5941
add /update-memory command to manually update .agent-files/
charles-cooper Dec 14, 2025
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: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ opencode.json
a.out
target
.agent-files/
opencodetmp/
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ export function Autocomplete(props: {
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
{
display: "/debug-compaction",
description: "show compaction summary and handoff",
onSelect: () => command.trigger("session.debug-compaction"),
},
{
display: "/update-memory",
description: "prompt model to update .agent-files/",
onSelect: () => command.trigger("session.update-memory"),
},
{
display: "/unshare",
disabled: !s.share,
Expand Down
61 changes: 60 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,49 @@ export function Session() {
dialog.clear()
},
},
{
title: "Debug compaction",
value: "session.debug-compaction",
category: "Session",
onSelect: async (dialog) => {
const s = session()
const sessionMessages = messages()

// Find compaction summary message (assistant message with summary: true)
const summaryMsg = sessionMessages.find((m) => m.role === "assistant" && (m as AssistantMessage).summary) as
| AssistantMessage
| undefined

let output = "# Compaction Debug\n\n"

// Handoff prompt
if (s.handoff) {
output += `## Handoff Prompt\n\n`
output += `**Trigger:** ${s.handoff.trigger}\n`
output += `**Created:** ${new Date(s.handoff.createdAt).toLocaleString()}\n\n`
output += `\`\`\`\n${s.handoff.prompt}\n\`\`\`\n\n`
} else {
output += `## Handoff Prompt\n\nNo handoff stored.\n\n`
}

// Summary message content
if (summaryMsg) {
const parts = sync.data.part[summaryMsg.id] ?? []
const textParts = parts.filter((p): p is TextPart => p.type === "text")
output += `## Summary Message\n\n`
output += `**Message ID:** ${summaryMsg.id}\n\n`
for (const part of textParts) {
output += `${part.text}\n\n`
}
} else {
output += `## Summary Message\n\nNo compaction summary found.\n\n`
}

await Clipboard.copy(output)
toast.show({ message: "Compaction debug info copied to clipboard!", variant: "success" })
dialog.clear()
},
},
{
title: "Unshare session",
value: "session.unshare",
Expand All @@ -331,6 +374,20 @@ export function Session() {
dialog.clear()
},
},
{
title: "Update memory",
value: "session.update-memory",
category: "Session",
onSelect: (dialog) => {
prompt.set({
input:
"Update .agent-files/ now. Review what changed this session and update STATUS.md, any active TASK_*.md files, and MEDIUMTERM_MEM.md/LONGTERM_MEM.md if there are lasting learnings. Be thorough.",
parts: [],
})
dialog.clear()
command.trigger("prompt.submit")
},
},
{
title: "Undo previous message",
value: "session.undo",
Expand Down Expand Up @@ -1098,7 +1155,9 @@ function UserMessage(props: {
<box marginTop={1} border={["top"]} titleAlignment="center" borderColor={theme.borderActive}>
<box flexDirection="row" gap={1} justifyContent="center" paddingTop={1} paddingBottom={1}>
<spinner frames={spinnerFrames} interval={80} color={theme.borderActive} />
<text fg={theme.textMuted}>Auto-optimizing context...</text>
<text fg={theme.textMuted}>
{compaction()?.trigger === "overflow" ? "Auto-optimizing context..." : "Optimizing context..."}
</text>
</box>
</box>
</Show>
Expand Down
51 changes: 35 additions & 16 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { Log } from "../util/log"
import { ProviderTransform } from "@/provider/transform"
import { fn } from "@/util/fn"
import { mergeDeep, pipe } from "remeda"
import path from "path"
import fs from "fs/promises"

export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
Expand Down Expand Up @@ -86,8 +88,28 @@ export namespace SessionCompaction {
}

const CompactionSchema = z.object({
summary: z.string().describe("What was done, files modified, key decisions, user constraints"),
continue: z.string().describe("Specific next steps, context for continuation, pending tasks, relevant files"),
summary: z
.string()
.describe(
"Comprehensive handoff: files changed, decisions, errors, user preferences, implementation details. Include everything needed to continue.",
),
continue: z
.string()
.describe(
"Focused instruction for immediate next steps. Include task and current direction (what's been tried/ruled out, what approach to take). This becomes the user message that resumes work.",
),
files: z
.array(
z.object({
path: z.string().describe("Relative path within .agent-files/ (e.g. 'STATUS.md', 'notes/debug.md')"),
content: z.string().describe("Full file content"),
}),
)
.optional()
.default([])
.describe(
"Files to write to .agent-files/ directory. Always include STATUS.md with current state. Add other files as needed for context that should persist across sessions.",
),
})

export async function process(input: {
Expand Down Expand Up @@ -200,20 +222,6 @@ export namespace SessionCompaction {
msg.time.completed = Date.now()
await Session.updateMessage(msg)

// Create text part for UI display
const displayText = `## Summary\n${result.object.summary}\n\n## Continue\n${result.object.continue}`
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: input.sessionID,
type: "text",
text: displayText,
time: {
start: msg.time.created,
end: Date.now(),
},
})

// Store handoff prompt in session
await Session.update(input.sessionID, (draft) => {
draft.handoff = {
Expand All @@ -223,6 +231,17 @@ export namespace SessionCompaction {
}
})

// Write agent files
if (result.object.files?.length) {
const agentDir = path.join(Instance.directory, ".agent-files")
for (const file of result.object.files) {
const filePath = path.join(agentDir, file.path)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await Bun.file(filePath).write(file.content)
log.info("wrote agent file", { path: filePath })
}
}

// For non-user triggers, inject continuation as synthetic user message
if (input.trigger !== "user") {
const continueMsg = await Session.updateMessage({
Expand Down
161 changes: 158 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,9 +446,12 @@ export namespace SessionPrompt {
const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.maxSteps ?? Infinity
const isLastStep = step >= maxSteps
msgs = insertReminders({
msgs = await insertReminders({
messages: msgs,
agent,
lastFinished,
model,
sessionID,
})
const processor = SessionProcessor.create({
assistantMessage: (await Session.updateMessage({
Expand Down Expand Up @@ -654,7 +657,8 @@ export namespace SessionPrompt {
if (result === "stop") break
continue
}
SessionCompaction.prune({ sessionID })
// Pruning disabled - model has better semantic understanding of what's important
// SessionCompaction.prune({ sessionID })
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user") continue
const queued = state()[sessionID]?.callbacks ?? []
Expand Down Expand Up @@ -1132,9 +1136,160 @@ export namespace SessionPrompt {
}
}

function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
function getCompactionNudge(utilization: number): string {
if (utilization >= 0.8) {
return "\n\nCRITICAL: Context at 80%. Stop and compact."
}
if (utilization >= 0.7) {
return "\n\nWARNING: Context at 70%. Compact soon."
}
if (utilization >= 0.6) {
return "\n\nContext at 60%. Compact now if you've completed a task phase."
}
if (utilization >= 0.5) {
return "\n\nContext at 50%. Good time to compact at next breakpoint."
}
if (utilization >= 0.4) {
return "\n\nContext at 40%. Consider compacting if finished a unit of work."
}
if (utilization >= 0.3) {
return "\n\nContext at 30%. Compaction is cheap - consider it at breakpoints."
}
if (utilization >= 0.2) {
return "\n\nContext at 20%. Compaction is cheap with caching."
}
return ""
}

function getAgentFilesNudge(utilization: number): string {
if (utilization >= 0.8) {
return "<system-reminder>Update .agent-files/ NOW before compacting.</system-reminder>"
}
if (utilization >= 0.7) {
return "<system-reminder>Update .agent-files/ before compacting.</system-reminder>"
}
if (utilization >= 0.4) {
return "<system-reminder>Keep .agent-files/ updated if state has changed.</system-reminder>"
}
if (utilization >= 0.2) {
return "<system-reminder>Keep .agent-files/ updated as you work.</system-reminder>"
}
return ""
}

function getSelfCheckNudge(percent: number): string {
// Trigger self-check at 20%, 30%, 40%, 50%, 60%, 70%, 80%, 90%
if (percent >= 20 && percent % 10 < 3) {
return "<self-check>Pause: What are your core instructions? If you can't clearly recall them, compact now.</self-check>"
}
return ""
}

async function getMemoryFileReminder(sessionID: string, messageCount: number): Promise<string | null> {
// Only remind in early part of session (first 10 messages) to avoid spam
if (messageCount > 10) return null

const agentFilesDir = `${Instance.directory}/.agent-files`
const memoryFiles = ["STATUS.md", "LONGTERM_MEM.md", "MEDIUMTERM_MEM.md"]

// Check if .agent-files/ directory exists
const stat = await fs.stat(agentFilesDir).catch(() => null)
if (!stat?.isDirectory()) return null

// Get files that exist but haven't been read this session
const unreadFiles: string[] = []
for (const file of memoryFiles) {
const filepath = `${agentFilesDir}/${file}`
const exists = await Bun.file(filepath)
.exists()
.catch(() => false)
if (exists) {
const readTime = FileTime.get(sessionID, filepath)
if (!readTime) {
unreadFiles.push(file)
}
}
}

if (unreadFiles.length === 0) return null

return `<system-reminder>Unread memory files: ${unreadFiles.join(", ")} - read these to restore prior session context.</system-reminder>`
}

async function insertReminders(input: {
messages: MessageV2.WithParts[]
agent: Agent.Info
lastFinished?: MessageV2.Assistant
model: Provider.Model
sessionID: string
}) {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages

// Add context utilization status when > 15%
if (input.lastFinished && input.model.limit.context > 0) {
const tokens = input.lastFinished.tokens
const used = tokens.input + tokens.cache.read + tokens.cache.write + tokens.output
const capacity = input.model.limit.context
const utilization = used / capacity

if (utilization > 0.15) {
const percent = Math.round(utilization * 100)
const usedK = Math.round(used / 1000)
const capacityK = Math.round(capacity / 1000)

// Context status with compaction nudge
const compactionNudge = getCompactionNudge(utilization)
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: `<context-status>${percent}% of context window used (${usedK}k/${capacityK}k tokens)${compactionNudge}</context-status>`,
synthetic: true,
})

// Agent files nudge (separate part)
const agentFilesNudge = getAgentFilesNudge(utilization)
if (agentFilesNudge) {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: agentFilesNudge,
synthetic: true,
})
}

// Self-check nudge (separate part)
const selfCheckNudge = getSelfCheckNudge(percent)
if (selfCheckNudge) {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: selfCheckNudge,
synthetic: true,
})
}
}
}

// Check for unread memory files (only on first few messages to avoid spam)
const memoryReminder = await getMemoryFileReminder(input.sessionID, input.messages.length)
if (memoryReminder) {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: memoryReminder,
synthetic: true,
})
}

if (input.agent.name === "plan") {
userMessage.parts.push({
id: Identifier.ascending("part"),
Expand Down
Loading