feat(coding-agent): context compaction with /compact, /autocompact, and auto-trigger

- Add /compact command for manual context compaction with optional custom instructions
- Add /autocompact command to toggle automatic compaction
- Auto-trigger compaction when context usage exceeds threshold (contextWindow - reserveTokens)
- Add CompactionComponent for TUI display with collapsed/expanded states
- Add compaction events to HTML export with collapsible summary
- Refactor export-html.ts to eliminate duplication between session and streaming formats
- Use setTimeout to break out of agent event handler for safe async compaction
- Show compaction summary in TUI after compaction completes

fixes #92
This commit is contained in:
Mario Zechner 2025-12-04 02:39:54 +01:00
parent bddb99fa7c
commit c89b1ec3c2
6 changed files with 803 additions and 1473 deletions

View file

@ -8,7 +8,7 @@
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai";
import { type CompactionEntry, loadSessionFromEntries, type SessionEntry } from "./session-manager.js";
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
// ============================================================================
// Types
@ -225,8 +225,10 @@ export async function compact(
signal?: AbortSignal,
customInstructions?: string,
): Promise<CompactionEntry> {
// Reconstruct current messages from entries
const { messages: currentMessages } = loadSessionFromEntries(entries);
// Don't compact if the last entry is already a compaction
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
throw new Error("Already compacted");
}
// Find previous compaction boundary
let prevCompactionIndex = -1;
@ -246,9 +248,29 @@ export async function compact(
// Find cut point (entry index) within the valid range
const firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Generate summary from the full current context
// Extract messages to summarize (before the cut point)
const messagesToSummarize: AppMessage[] = [];
for (let i = boundaryStart; i < firstKeptEntryIndex; i++) {
const entry = entries[i];
if (entry.type === "message") {
messagesToSummarize.push(entry.message);
}
}
// Also include the previous summary if there was a compaction
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
// Prepend the previous summary as context
messagesToSummarize.unshift({
role: "user",
content: `Previous session summary:\n${prevCompaction.summary}`,
timestamp: Date.now(),
});
}
// Generate summary from messages before the cut point
const summary = await generateSummary(
currentMessages,
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,

File diff suppressed because it is too large Load diff

View file

@ -72,17 +72,21 @@ export interface LoadedSession {
model: { provider: string; modelId: string } | null;
}
const SUMMARY_PREFIX = `Another language model worked on this task and produced a summary. Use this to continue the work without duplicating effort:
export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
<summary>
`;
export const SUMMARY_SUFFIX = `
</summary>`;
/**
* Create a user message containing the summary with the standard prefix.
*/
export function createSummaryMessage(summary: string): AppMessage {
return {
role: "user",
content: SUMMARY_PREFIX + summary,
content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX,
timestamp: Date.now(),
};
}
@ -115,6 +119,18 @@ export function parseSessionEntries(content: string): SessionEntry[] {
* 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages)
* 3. Prepend summary as user message
*/
/**
* Get the latest compaction entry from session entries, if any.
*/
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
return entries[i] as CompactionEntry;
}
}
return null;
}
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
// Find model and thinking level (always scan all entries)
let thinkingLevel = "off";

View file

@ -0,0 +1,54 @@
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a compaction indicator with collapsed/expanded state.
* Collapsed: shows "Context compacted from X tokens"
* Expanded: shows the full summary rendered as markdown (like a user message)
*/
export class CompactionComponent extends Container {
private expanded = false;
private tokensBefore: number;
private summary: string;
constructor(tokensBefore: number, summary: string) {
super();
this.tokensBefore = tokensBefore;
this.summary = summary;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
this.addChild(new Spacer(1));
if (this.expanded) {
// Show header + summary as markdown (like user message)
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
this.addChild(
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
bgColor: (text: string) => theme.bg("userMessageBg", text),
color: (text: string) => theme.fg("userMessageText", text),
}),
);
} else {
// Collapsed: just show the header line with user message styling
const isMac = process.platform === "darwin";
const shortcut = isMac ? "CMD+O" : "CTRL+O";
this.addChild(
new Text(
theme.fg("userMessageText", `--- Earlier messages compacted (${shortcut} to expand) ---`),
1,
1,
(text: string) => theme.bg("userMessageBg", text),
),
);
}
this.addChild(new Spacer(1));
}
}

View file

@ -22,12 +22,19 @@ import { calculateContextTokens, compact, getLastAssistantUsage, shouldCompact }
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
import { exportSessionToHtml } from "../export-html.js";
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
import { listOAuthProviders, login, logout } from "../oauth/index.js";
import { loadSessionFromEntries, type SessionManager } from "../session-manager.js";
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
import {
getLatestCompactionEntry,
loadSessionFromEntries,
type SessionManager,
SUMMARY_PREFIX,
SUMMARY_SUFFIX,
} from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { CompactionComponent } from "./compaction.js";
import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js";
import { FooterComponent } from "./footer.js";
@ -564,58 +571,7 @@ export class TuiRenderer {
if (!shouldCompact(contextTokens, contextWindow, settings)) return;
// Trigger auto-compaction
await this.handleAutoCompaction();
}
private async handleAutoCompaction(): Promise<void> {
// Unsubscribe to stop processing events
this.unsubscribe?.();
// Abort current agent run and wait for completion
this.agent.abort();
await this.agent.waitForIdle();
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
}
this.statusContainer.clear();
// Show compacting status
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("muted", "Auto-compacting context..."), 1, 1));
this.ui.requestRender();
try {
const apiKey = await getApiKeyForModel(this.agent.state.model);
if (!apiKey) {
throw new Error(`No API key for ${this.agent.state.model.provider}`);
}
const entries = this.sessionManager.loadEntries();
const settings = this.settingsManager.getCompactionSettings();
const compactionEntry = await compact(entries, this.agent.state.model, settings, apiKey);
// Save and reload
this.sessionManager.saveCompaction(compactionEntry);
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
this.agent.replaceMessages(loaded.messages);
// Rebuild UI
this.chatContainer.clear();
this.rebuildChatFromMessages();
this.showSuccess(
"✓ Context auto-compacted",
`Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`,
);
} catch (error) {
this.showError(`Auto-compaction failed: ${error instanceof Error ? error.message : String(error)}`);
}
// Resubscribe
this.subscribeToAgent();
await this.executeCompaction(undefined, true);
}
private async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
@ -648,9 +604,12 @@ export class TuiRenderer {
case "message_start":
if (event.message.role === "user") {
// Check if this is a queued message
const userMsg = event.message as any;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
const messageText = textBlocks.map((c: any) => c.text).join("");
const userMsg = event.message;
const textBlocks =
typeof userMsg.content === "string"
? [{ type: "text", text: userMsg.content }]
: userMsg.content.filter((c) => c.type === "text");
const messageText = textBlocks.map((c) => c.text).join("");
const queuedIndex = this.queuedMessages.indexOf(messageText);
if (queuedIndex !== -1) {
@ -789,17 +748,20 @@ export class TuiRenderer {
private addMessageToChat(message: Message): void {
if (message.role === "user") {
const userMsg = message as any;
const userMsg = message;
// Extract text content from content blocks
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
const textContent = textBlocks.map((c: any) => c.text).join("");
const textBlocks =
typeof userMsg.content === "string"
? [{ type: "text", text: userMsg.content }]
: userMsg.content.filter((c) => c.type === "text");
const textContent = textBlocks.map((c) => c.text).join("");
if (textContent) {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
this.chatContainer.addChild(userComponent);
this.isFirstUserMessage = false;
}
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
const assistantMsg = message;
// Add assistant message component
const assistantComponent = new AssistantMessageComponent(assistantMsg);
@ -819,18 +781,32 @@ export class TuiRenderer {
// Update editor border color based on current thinking level
this.updateEditorBorderColor();
// Get compaction info if any
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
// Render messages
for (let i = 0; i < state.messages.length; i++) {
const message = state.messages[i];
if (message.role === "user") {
const userMsg = message as any;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
const textContent = textBlocks.map((c: any) => c.text).join("");
const userMsg = message;
const textBlocks =
typeof userMsg.content === "string"
? [{ type: "text", text: userMsg.content }]
: userMsg.content.filter((c) => c.type === "text");
const textContent = textBlocks.map((c) => c.text).join("");
if (textContent) {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
this.chatContainer.addChild(userComponent);
this.isFirstUserMessage = false;
// Check if this is a compaction summary message
if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
} else {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
this.chatContainer.addChild(userComponent);
this.isFirstUserMessage = false;
}
}
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
@ -892,18 +868,32 @@ export class TuiRenderer {
this.isFirstUserMessage = true;
this.pendingTools.clear();
// Get compaction info if any
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
for (const message of this.agent.state.messages) {
if (message.role === "user") {
const userMsg = message as any;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
const textContent = textBlocks.map((c: any) => c.text).join("");
const userMsg = message;
const textBlocks =
typeof userMsg.content === "string"
? [{ type: "text", text: userMsg.content }]
: userMsg.content.filter((c) => c.type === "text");
const textContent = textBlocks.map((c) => c.text).join("");
if (textContent) {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
this.chatContainer.addChild(userComponent);
this.isFirstUserMessage = false;
// Check if this is a compaction summary message
if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
} else {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
this.chatContainer.addChild(userComponent);
this.isFirstUserMessage = false;
}
}
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
const assistantMsg = message;
const assistantComponent = new AssistantMessageComponent(assistantMsg);
this.chatContainer.addChild(assistantComponent);
@ -1095,10 +1085,12 @@ export class TuiRenderer {
private toggleToolOutputExpansion(): void {
this.toolOutputExpanded = !this.toolOutputExpanded;
// Update all tool execution components
// Update all tool execution and compaction components
for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
child.setExpanded(this.toolOutputExpanded);
} else if (child instanceof CompactionComponent) {
child.setExpanded(this.toolOutputExpanded);
}
}
@ -1445,7 +1437,7 @@ export class TuiRenderer {
// Create OAuth selector
this.oauthSelector = new OAuthSelectorComponent(
mode,
async (providerId: any) => {
async (providerId: string) => {
// Hide selector first
this.hideOAuthSelector();
@ -1457,7 +1449,7 @@ export class TuiRenderer {
try {
await login(
providerId,
providerId as SupportedOAuthProvider,
(url: string) => {
// Show auth URL to user
this.chatContainer.addChild(new Spacer(1));
@ -1509,7 +1501,7 @@ export class TuiRenderer {
} else {
// Handle logout
try {
await logout(providerId);
await logout(providerId as SupportedOAuthProvider);
// Invalidate OAuth cache so footer updates
invalidateOAuthCache();
@ -1707,7 +1699,7 @@ export class TuiRenderer {
private handleDebugCommand(): void {
// Force a render and capture all lines with their widths
const width = (this.ui as any).terminal.columns;
const width = this.ui.terminal.columns;
const allLines = this.ui.render(width);
const debugLogPath = getDebugLogPath();
@ -1737,16 +1729,13 @@ export class TuiRenderer {
this.ui.requestRender();
}
private async handleCompactCommand(customInstructions?: string): Promise<void> {
// Check if there are any messages to compact
const entries = this.sessionManager.loadEntries();
const messageCount = entries.filter((e) => e.type === "message").length;
if (messageCount < 2) {
this.showWarning("Nothing to compact (no messages yet)");
return;
}
private compactionAbortController: AbortController | null = null;
/**
* Shared logic to execute context compaction.
* Handles aborting agent, showing loader, performing compaction, updating session/UI.
*/
private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
// Unsubscribe first to prevent processing events during compaction
this.unsubscribe?.();
@ -1761,9 +1750,27 @@ export class TuiRenderer {
}
this.statusContainer.clear();
// Show compacting status
// Create abort controller for compaction
this.compactionAbortController = new AbortController();
// Set up escape handler during compaction
const originalOnEscape = this.editor.onEscape;
this.editor.onEscape = () => {
if (this.compactionAbortController) {
this.compactionAbortController.abort();
}
};
// Show compacting status with loader
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("muted", "Compacting context..."), 1, 1));
const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
const compactingLoader = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
label,
);
this.statusContainer.addChild(compactingLoader);
this.ui.requestRender();
try {
@ -1773,17 +1780,23 @@ export class TuiRenderer {
throw new Error(`No API key for ${this.agent.state.model.provider}`);
}
// Perform compaction
// Perform compaction with abort signal
const entries = this.sessionManager.loadEntries();
const settings = this.settingsManager.getCompactionSettings();
const compactionEntry = await compact(
entries,
this.agent.state.model,
settings,
apiKey,
undefined,
this.compactionAbortController.signal,
customInstructions,
);
// Check if aborted after compact returned
if (this.compactionAbortController.signal.aborted) {
throw new Error("Compaction cancelled");
}
// Save compaction to session
this.sessionManager.saveCompaction(compactionEntry);
@ -1795,19 +1808,49 @@ export class TuiRenderer {
this.chatContainer.clear();
this.rebuildChatFromMessages();
// Show success
this.showSuccess(
"✓ Context compacted",
`Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`,
);
// Add compaction component at current position so user can see/expand the summary
const compactionComponent = new CompactionComponent(compactionEntry.tokensBefore, compactionEntry.summary);
compactionComponent.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(compactionComponent);
// Update footer with new state (fixes context % display)
this.footer.updateState(this.agent.state);
// Show success message
const successTitle = isAuto ? "✓ Context auto-compacted" : "✓ Context compacted";
this.showSuccess(successTitle, `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`);
} catch (error) {
this.showError(`Compaction failed: ${error instanceof Error ? error.message : String(error)}`);
const message = error instanceof Error ? error.message : String(error);
if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
this.showError("Compaction cancelled");
} else {
this.showError(`Compaction failed: ${message}`);
}
} finally {
// Clean up
compactingLoader.stop();
this.statusContainer.clear();
this.compactionAbortController = null;
this.editor.onEscape = originalOnEscape;
}
// Resubscribe to agent
this.subscribeToAgent();
}
private async handleCompactCommand(customInstructions?: string): Promise<void> {
// Check if there are any messages to compact
const entries = this.sessionManager.loadEntries();
const messageCount = entries.filter((e) => e.type === "message").length;
if (messageCount < 2) {
this.showWarning("Nothing to compact (no messages yet)");
return;
}
await this.executeCompaction(customInstructions, false);
}
private handleAutocompactCommand(): void {
const currentEnabled = this.settingsManager.getCompactionEnabled();
const newState = !currentEnabled;