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 { AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete } 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 // Types
@ -225,8 +225,10 @@ export async function compact(
signal?: AbortSignal, signal?: AbortSignal,
customInstructions?: string, customInstructions?: string,
): Promise<CompactionEntry> { ): Promise<CompactionEntry> {
// Reconstruct current messages from entries // Don't compact if the last entry is already a compaction
const { messages: currentMessages } = loadSessionFromEntries(entries); if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
throw new Error("Already compacted");
}
// Find previous compaction boundary // Find previous compaction boundary
let prevCompactionIndex = -1; let prevCompactionIndex = -1;
@ -246,9 +248,29 @@ export async function compact(
// Find cut point (entry index) within the valid range // Find cut point (entry index) within the valid range
const firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens); 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( const summary = await generateSummary(
currentMessages, messagesToSummarize,
model, model,
settings.reserveTokens, settings.reserveTokens,
apiKey, 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; 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. * Create a user message containing the summary with the standard prefix.
*/ */
export function createSummaryMessage(summary: string): AppMessage { export function createSummaryMessage(summary: string): AppMessage {
return { return {
role: "user", role: "user",
content: SUMMARY_PREFIX + summary, content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX,
timestamp: Date.now(), timestamp: Date.now(),
}; };
} }
@ -115,6 +119,18 @@ export function parseSessionEntries(content: string): SessionEntry[] {
* 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages) * 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages)
* 3. Prepend summary as user message * 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 { export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
// Find model and thinking level (always scan all entries) // Find model and thinking level (always scan all entries)
let thinkingLevel = "off"; 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 { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
import { exportSessionToHtml } from "../export-html.js"; import { exportSessionToHtml } from "../export-html.js";
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js"; import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
import { listOAuthProviders, login, logout } from "../oauth/index.js"; import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
import { loadSessionFromEntries, type SessionManager } from "../session-manager.js"; import {
getLatestCompactionEntry,
loadSessionFromEntries,
type SessionManager,
SUMMARY_PREFIX,
SUMMARY_SUFFIX,
} from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js"; import type { SettingsManager } from "../settings-manager.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js"; import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js"; import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js"; import { AssistantMessageComponent } from "./assistant-message.js";
import { CompactionComponent } from "./compaction.js";
import { CustomEditor } from "./custom-editor.js"; import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { FooterComponent } from "./footer.js"; import { FooterComponent } from "./footer.js";
@ -564,58 +571,7 @@ export class TuiRenderer {
if (!shouldCompact(contextTokens, contextWindow, settings)) return; if (!shouldCompact(contextTokens, contextWindow, settings)) return;
// Trigger auto-compaction // Trigger auto-compaction
await this.handleAutoCompaction(); await this.executeCompaction(undefined, true);
}
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();
} }
private async handleEvent(event: AgentEvent, state: AgentState): Promise<void> { private async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
@ -648,9 +604,12 @@ export class TuiRenderer {
case "message_start": case "message_start":
if (event.message.role === "user") { if (event.message.role === "user") {
// Check if this is a queued message // Check if this is a queued message
const userMsg = event.message as any; const userMsg = event.message;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textBlocks =
const messageText = textBlocks.map((c: any) => c.text).join(""); 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); const queuedIndex = this.queuedMessages.indexOf(messageText);
if (queuedIndex !== -1) { if (queuedIndex !== -1) {
@ -789,17 +748,20 @@ export class TuiRenderer {
private addMessageToChat(message: Message): void { private addMessageToChat(message: Message): void {
if (message.role === "user") { if (message.role === "user") {
const userMsg = message as any; const userMsg = message;
// Extract text content from content blocks // Extract text content from content blocks
const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textBlocks =
const textContent = textBlocks.map((c: any) => c.text).join(""); 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) { if (textContent) {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
this.chatContainer.addChild(userComponent); this.chatContainer.addChild(userComponent);
this.isFirstUserMessage = false; this.isFirstUserMessage = false;
} }
} else if (message.role === "assistant") { } else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage; const assistantMsg = message;
// Add assistant message component // Add assistant message component
const assistantComponent = new AssistantMessageComponent(assistantMsg); const assistantComponent = new AssistantMessageComponent(assistantMsg);
@ -819,18 +781,32 @@ export class TuiRenderer {
// Update editor border color based on current thinking level // Update editor border color based on current thinking level
this.updateEditorBorderColor(); this.updateEditorBorderColor();
// Get compaction info if any
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
// Render messages // Render messages
for (let i = 0; i < state.messages.length; i++) { for (let i = 0; i < state.messages.length; i++) {
const message = state.messages[i]; const message = state.messages[i];
if (message.role === "user") { if (message.role === "user") {
const userMsg = message as any; const userMsg = message;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textBlocks =
const textContent = textBlocks.map((c: any) => c.text).join(""); 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) { if (textContent) {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); // Check if this is a compaction summary message
this.chatContainer.addChild(userComponent); if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
this.isFirstUserMessage = false; 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") { } else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage; const assistantMsg = message as AssistantMessage;
@ -892,18 +868,32 @@ export class TuiRenderer {
this.isFirstUserMessage = true; this.isFirstUserMessage = true;
this.pendingTools.clear(); this.pendingTools.clear();
// Get compaction info if any
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
for (const message of this.agent.state.messages) { for (const message of this.agent.state.messages) {
if (message.role === "user") { if (message.role === "user") {
const userMsg = message as any; const userMsg = message;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textBlocks =
const textContent = textBlocks.map((c: any) => c.text).join(""); 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) { if (textContent) {
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); // Check if this is a compaction summary message
this.chatContainer.addChild(userComponent); if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
this.isFirstUserMessage = false; 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") { } else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage; const assistantMsg = message;
const assistantComponent = new AssistantMessageComponent(assistantMsg); const assistantComponent = new AssistantMessageComponent(assistantMsg);
this.chatContainer.addChild(assistantComponent); this.chatContainer.addChild(assistantComponent);
@ -1095,10 +1085,12 @@ export class TuiRenderer {
private toggleToolOutputExpansion(): void { private toggleToolOutputExpansion(): void {
this.toolOutputExpanded = !this.toolOutputExpanded; this.toolOutputExpanded = !this.toolOutputExpanded;
// Update all tool execution components // Update all tool execution and compaction components
for (const child of this.chatContainer.children) { for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) { if (child instanceof ToolExecutionComponent) {
child.setExpanded(this.toolOutputExpanded); child.setExpanded(this.toolOutputExpanded);
} else if (child instanceof CompactionComponent) {
child.setExpanded(this.toolOutputExpanded);
} }
} }
@ -1445,7 +1437,7 @@ export class TuiRenderer {
// Create OAuth selector // Create OAuth selector
this.oauthSelector = new OAuthSelectorComponent( this.oauthSelector = new OAuthSelectorComponent(
mode, mode,
async (providerId: any) => { async (providerId: string) => {
// Hide selector first // Hide selector first
this.hideOAuthSelector(); this.hideOAuthSelector();
@ -1457,7 +1449,7 @@ export class TuiRenderer {
try { try {
await login( await login(
providerId, providerId as SupportedOAuthProvider,
(url: string) => { (url: string) => {
// Show auth URL to user // Show auth URL to user
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
@ -1509,7 +1501,7 @@ export class TuiRenderer {
} else { } else {
// Handle logout // Handle logout
try { try {
await logout(providerId); await logout(providerId as SupportedOAuthProvider);
// Invalidate OAuth cache so footer updates // Invalidate OAuth cache so footer updates
invalidateOAuthCache(); invalidateOAuthCache();
@ -1707,7 +1699,7 @@ export class TuiRenderer {
private handleDebugCommand(): void { private handleDebugCommand(): void {
// Force a render and capture all lines with their widths // 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 allLines = this.ui.render(width);
const debugLogPath = getDebugLogPath(); const debugLogPath = getDebugLogPath();
@ -1737,16 +1729,13 @@ export class TuiRenderer {
this.ui.requestRender(); this.ui.requestRender();
} }
private async handleCompactCommand(customInstructions?: string): Promise<void> { private compactionAbortController: AbortController | null = null;
// 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;
}
/**
* 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 // Unsubscribe first to prevent processing events during compaction
this.unsubscribe?.(); this.unsubscribe?.();
@ -1761,9 +1750,27 @@ export class TuiRenderer {
} }
this.statusContainer.clear(); 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 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(); this.ui.requestRender();
try { try {
@ -1773,17 +1780,23 @@ export class TuiRenderer {
throw new Error(`No API key for ${this.agent.state.model.provider}`); 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 settings = this.settingsManager.getCompactionSettings();
const compactionEntry = await compact( const compactionEntry = await compact(
entries, entries,
this.agent.state.model, this.agent.state.model,
settings, settings,
apiKey, apiKey,
undefined, this.compactionAbortController.signal,
customInstructions, customInstructions,
); );
// Check if aborted after compact returned
if (this.compactionAbortController.signal.aborted) {
throw new Error("Compaction cancelled");
}
// Save compaction to session // Save compaction to session
this.sessionManager.saveCompaction(compactionEntry); this.sessionManager.saveCompaction(compactionEntry);
@ -1795,19 +1808,49 @@ export class TuiRenderer {
this.chatContainer.clear(); this.chatContainer.clear();
this.rebuildChatFromMessages(); this.rebuildChatFromMessages();
// Show success // Add compaction component at current position so user can see/expand the summary
this.showSuccess( const compactionComponent = new CompactionComponent(compactionEntry.tokensBefore, compactionEntry.summary);
"✓ Context compacted", compactionComponent.setExpanded(this.toolOutputExpanded);
`Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`, 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) { } 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 // Resubscribe to agent
this.subscribeToAgent(); 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 { private handleAutocompactCommand(): void {
const currentEnabled = this.settingsManager.getCompactionEnabled(); const currentEnabled = this.settingsManager.getCompactionEnabled();
const newState = !currentEnabled; const newState = !currentEnabled;

View file

@ -73,7 +73,7 @@ export class Container implements Component {
* TUI - Main class for managing terminal UI with differential rendering * TUI - Main class for managing terminal UI with differential rendering
*/ */
export class TUI extends Container { export class TUI extends Container {
private terminal: Terminal; public terminal: Terminal;
private previousLines: string[] = []; private previousLines: string[] = [];
private previousWidth = 0; private previousWidth = 0;
private focusedComponent: Component | null = null; private focusedComponent: Component | null = null;