From 042c3e8b29dba03f34b1c3f78ce58f3cd5a3b215 Mon Sep 17 00:00:00 2001 From: lewis617 Date: Mon, 1 Jun 2026 15:55:14 +0800 Subject: [PATCH] feat: add /compact slash command with PreCompact/PostCompact hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PreCompact and PostCompact hook event types (HookEvent, HookJsonInput, ExtendedHookExecutionContext) - Add customInstructions to CompactMessagesOptions for guided summarization - Extract public compactConversation() method and buildPostCompactContext() helper from handleTokenUsageAndCompaction() - Refactor handleTokenUsageAndCompaction() to delegate to compactConversation() - Add /compact built-in slash command with optional custom instructions - Add executePreCompactHooks() and executePostCompactHooks() convenience methods to HookManager - Update HookManager for PreCompact/PostCompact (configApplies, validateEventConfig, handleBlockingError, getConfigurationStats) - Add 9 tests for compactConversation (hooks, custom instructions, circuit breaker, error handling) - Rename specs/014-message-compression to specs/014-message-compact - Update 005-hooks spec (US15/US16, FR-056–FR-062) and 014-message-compact spec (US6/US7, FR-014–FR-024) --- packages/agent-sdk/src/managers/aiManager.ts | 529 ++++++++++-------- .../agent-sdk/src/managers/hookManager.ts | 95 +++- .../src/managers/slashCommandManager.ts | 17 + packages/agent-sdk/src/services/aiService.ts | 5 +- packages/agent-sdk/src/types/hooks.ts | 10 +- .../aiManager.compactConversation.test.ts | 294 ++++++++++ specs/005-hooks/contracts/hooks-api.md | 15 +- specs/005-hooks/data-model.md | 17 +- specs/005-hooks/quickstart.md | 6 + specs/005-hooks/spec.md | 46 +- specs/005-hooks/tasks.md | 45 ++ .../claude-code-compact-logic.md | 0 specs/014-message-compact/data-model.md | 61 ++ .../plan.md | 28 +- specs/014-message-compact/quickstart.md | 39 ++ .../research.md | 2 +- .../spec.md | 72 ++- .../tasks.md | 37 +- specs/014-message-compression/data-model.md | 19 - specs/014-message-compression/quickstart.md | 26 - specs/README.md | 12 +- 21 files changed, 1048 insertions(+), 327 deletions(-) create mode 100644 packages/agent-sdk/tests/managers/aiManager.compactConversation.test.ts rename specs/{014-message-compression => 014-message-compact}/claude-code-compact-logic.md (100%) create mode 100644 specs/014-message-compact/data-model.md rename specs/{014-message-compression => 014-message-compact}/plan.md (62%) create mode 100644 specs/014-message-compact/quickstart.md rename specs/{014-message-compression => 014-message-compact}/research.md (97%) rename specs/{014-message-compression => 014-message-compact}/spec.md (60%) rename specs/{014-message-compression => 014-message-compact}/tasks.md (70%) delete mode 100644 specs/014-message-compression/data-model.md delete mode 100644 specs/014-message-compression/quickstart.md diff --git a/packages/agent-sdk/src/managers/aiManager.ts b/packages/agent-sdk/src/managers/aiManager.ts index 48726879a..3935d14f5 100644 --- a/packages/agent-sdk/src/managers/aiManager.ts +++ b/packages/agent-sdk/src/managers/aiManager.ts @@ -414,274 +414,327 @@ export class AIManager { `Token usage exceeded ${this.getMaxInputTokens()}, compacting messages...`, ); - // Check if messages need compaction const messagesToCompact = this.messageManager.getMessages(); + if (messagesToCompact.length === 0) return; - // If there are messages to compact, perform compaction - if (messagesToCompact.length > 0) { - // Circuit breaker: skip compaction after 3 consecutive failures - if (this.consecutiveCompactionFailures >= 3) { - logger?.warn( - `Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`, - ); - return; - } + // Circuit breaker: skip compaction after 3 consecutive failures + if (this.consecutiveCompactionFailures >= 3) { + logger?.warn( + `Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`, + ); + return; + } - const recentChatMessages = convertMessagesForAPI(messagesToCompact); + await this.compactConversation({ + abortSignal: abortController.signal, + }); + } + } - // Save session before compaction to preserve original messages - await this.messageManager.saveSession(); + /** + * Manually compact the conversation history. + * Called by /compact slash command or auto-compaction trigger. + */ + public async compactConversation( + options: { + customInstructions?: string; + abortSignal?: AbortSignal; + } = {}, + ): Promise { + const messagesToCompact = this.messageManager.getMessages(); + if (messagesToCompact.length === 0) { + logger?.debug("No messages to compact"); + return; + } - this.setIsCompacting(true); - try { - const compactResult = await aiService.compactMessages({ - gatewayConfig: this.getGatewayConfig(), - modelConfig: this.getModelConfig(), - messages: recentChatMessages, - abortSignal: abortController.signal, - model: this.getModelConfig().fastModel, - }); + // Circuit breaker: skip if already compacting + if (this.isCompacting) { + logger?.warn("Compaction already in progress"); + return; + } - // Handle usage tracking for compaction operations - let compactUsage: Usage | undefined; - if (compactResult.usage) { - compactUsage = { - prompt_tokens: compactResult.usage.prompt_tokens, - completion_tokens: compactResult.usage.completion_tokens, - total_tokens: compactResult.usage.total_tokens, - model: this.getModelConfig().fastModel, - operation_type: "compact", - }; - } + // 1. Run PreCompact hooks + let hookInstructions: string | undefined; + if (this.hookManager) { + try { + const preResult = await this.hookManager.executePreCompactHooks( + this.messageManager.getSessionId(), + this.messageManager.getTranscriptPath(), + options.customInstructions, + ); + hookInstructions = preResult.additionalInstructions; + } catch (error) { + logger?.warn(`PreCompact hooks failed: ${(error as Error).message}`); + } + } - // Build post-compact context restoration - const POST_COMPACT_TOKEN_BUDGET = 50_000; - const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000; - const POST_COMPACT_MAX_FILES_TO_RESTORE = 5; - const contextParts: string[] = []; + // 2. Merge custom instructions + const mergedInstructions = + [options.customInstructions, hookInstructions] + .filter(Boolean) + .join("\n") || undefined; - // 1. File context restoration - const recentFiles = this.messageManager.getRecentFileReads( - POST_COMPACT_MAX_FILES_TO_RESTORE, - POST_COMPACT_MAX_TOKENS_PER_FILE, - ); - let usedTokens = 0; - for (const file of recentFiles) { - const fileTokens = Math.ceil(file.content.length / 4); - if (usedTokens + fileTokens > POST_COMPACT_MAX_TOKENS_PER_FILE) - continue; - if (fileTokens > 0) usedTokens += fileTokens; - contextParts.push( - `\n\n## ${file.path}\n\`\`\`\n${file.content}\n\`\`\``, - ); - if (contextParts.length >= POST_COMPACT_MAX_FILES_TO_RESTORE) break; - if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break; - } + // 3. Save session before compaction + await this.messageManager.saveSession(); - // 2. Working directory - contextParts.push( - `\n\n[Working Directory]\nCurrent working directory: ${this.getWorkdir()}`, - ); + this.setIsCompacting(true); + try { + const recentChatMessages = convertMessagesForAPI(messagesToCompact); - // 3. Plan mode context - const currentMode = this.permissionManager?.getCurrentEffectiveMode( - this.getModelConfig().permissionMode, - ); - if (currentMode === "plan") { - const planFilePath = this.permissionManager?.getPlanFilePath(); - if (planFilePath) { - let planExists = false; - try { - await fs.access(planFilePath); - planExists = true; - } catch { - // Plan file doesn't exist yet - } - // Inject full plan mode system-reminder after compaction - // so the model retains plan mode constraints and instructions - contextParts.push( - `\n\n${buildPlanModeReminder(planFilePath, planExists, !!this.subagentType)}`, - ); - } - } + // 4. Call compactMessages with optional custom instructions + const compactResult = await aiService.compactMessages({ + gatewayConfig: this.getGatewayConfig(), + modelConfig: this.getModelConfig(), + messages: recentChatMessages, + abortSignal: options.abortSignal, + model: this.getModelConfig().fastModel, + customInstructions: mergedInstructions, + }); - // 4. Invoked skills context (with token budget, matching Claude Code) - const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000; - const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000; - const invokedSkillNames = - this.messageManager.getInvokedSkillNames(10); - if (invokedSkillNames.length > 0 && this.skillManager) { - const invokedSkillParts: string[] = []; - let skillsUsedTokens = 0; - for (const skillName of invokedSkillNames) { - try { - const skill = await this.skillManager.loadSkill(skillName); - if (!skill) continue; + // 5. Handle usage tracking + let compactUsage: Usage | undefined; + if (compactResult.usage) { + compactUsage = { + prompt_tokens: compactResult.usage.prompt_tokens, + completion_tokens: compactResult.usage.completion_tokens, + total_tokens: compactResult.usage.total_tokens, + model: this.getModelConfig().fastModel, + operation_type: "compact", + }; + } - // Extract content after frontmatter (matching prepareSkillContent pattern) - const contentMatch = skill.content.match( - /^---\n[\s\S]*?\n---\n([\s\S]*)$/, - ); - let skillContent = contentMatch - ? contentMatch[1].trim() - : skill.content; - - // Per-skill token budget enforcement (~4 chars per token) - const maxSkillChars = POST_COMPACT_MAX_TOKENS_PER_SKILL * 4; - if (skillContent.length > maxSkillChars) { - skillContent = - skillContent.slice(0, maxSkillChars) + - "\n\n...[truncated]..."; - } + // 6. Build post-compact context restoration + const enhancedSummary = await this.buildPostCompactContext( + compactResult.content, + ); - const skillTokens = Math.ceil(skillContent.length / 4); - if ( - skillsUsedTokens + skillTokens > - POST_COMPACT_SKILLS_TOKEN_BUDGET - ) - break; - skillsUsedTokens += skillTokens; + // 7. Execute message reconstruction + this.messageManager.compactMessagesAndUpdateSession( + enhancedSummary, + compactUsage, + ); - invokedSkillParts.push( - `\n\n## ${skill.name}\n${skill.description ? `*${skill.description}*\n\n` : ""}\`\`\`\n${skillContent}\n\`\`\``, - ); - } catch { - // Skip skills that can't be loaded - } - } - if (invokedSkillParts.length > 0) { - contextParts.push( - `\n\n[Invoked Skills]\n${invokedSkillParts.join("")}`, - ); - } - } + // 8. Track usage + if (compactUsage && this.callbacks?.onUsageAdded) { + this.callbacks.onUsageAdded(compactUsage); + } - // 5. Background subagent status (shell tasks excluded, matching Claude Code's createAsyncAgentAttachmentsIfNeeded) - const agents = - this.backgroundTaskManager - ?.getAllTasks() - .filter((a) => a.type === "subagent") || []; - if (agents.length > 0) { - const agentParts: string[] = []; - for (const a of agents) { - if (a.status === "killed") { - agentParts.push( - `Task "${a.description}" (${a.id}) was stopped by the user.`, - ); - } else if (a.status === "running") { - const parts = [ - `Background agent "${a.description}" (${a.id}) is still running.`, - `Do NOT spawn a duplicate. You will be notified when it completes.`, - ]; - if (a.outputPath) { - parts.push(`You can read partial output at ${a.outputPath}.`); - } - agentParts.push(parts.join(" ")); - } else { - // completed or failed - const parts = [ - `Task ${a.id} (status: ${a.status}) (description: ${a.description}).`, - ]; - const deltaText = a.status === "failed" ? a.stderr : a.stdout; - if (deltaText && deltaText.length > 0) { - const summary = - deltaText.length > 500 - ? deltaText.slice(0, 500) + "..." - : deltaText; - parts.push(`Delta: ${summary}`); - } - if (a.outputPath) { - parts.push( - `Read the output file to retrieve the result: ${a.outputPath}.`, - ); - } - agentParts.push(parts.join(" ")); - } - } - if (agentParts.length > 0) { - contextParts.push( - `\n\n[Background Tasks]\n${agentParts.join("\n")}`, - ); - } - } + this.consecutiveCompactionFailures = 0; - // Merge context restoration into summary - const enhancedSummary = - compactResult.content + - (contextParts.length > 0 - ? `\n\n[Context Restoration]` + contextParts.join("") - : ""); - - // Execute message reconstruction and sessionId update after compaction - this.messageManager.compactMessagesAndUpdateSession( - enhancedSummary, - compactUsage, - ); + // 9. Log OTEL event + logOTelEvent("compaction", { + beforeTokens: String(messagesToCompact.length), + afterTokens: "1", + model: this.getModelConfig().fastModel, + }).catch(() => {}); - // Notify Agent to add to usage tracking - if (compactUsage && this.callbacks?.onUsageAdded) { - this.callbacks.onUsageAdded(compactUsage); + // 10. Run SessionStart hooks (existing behavior) + if (this.hookManager) { + try { + const newSessionId = this.messageManager.getSessionId(); + const sessionStartResult = + await this.hookManager.executeSessionStartHooks( + "compact", + newSessionId, + this.messageManager.getTranscriptPath(), + this.subagentType, + ); + if (sessionStartResult.additionalContext) { + this.messageManager.addUserMessage({ + content: `\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n`, + isMeta: true, + }); } + if (sessionStartResult.initialUserMessage) { + this.messageManager.addUserMessage({ + content: sessionStartResult.initialUserMessage, + isMeta: true, + }); + } + } catch (error) { + logger?.warn( + `SessionStart hooks on compact failed: ${(error as Error).message}`, + ); + } + } - logger?.debug( - `Successfully compacted ${messagesToCompact.length} messages and updated session`, + // 11. Run PostCompact hooks + if (this.hookManager) { + try { + await this.hookManager.executePostCompactHooks( + this.messageManager.getSessionId(), + this.messageManager.getTranscriptPath(), + compactResult.content, ); - this.consecutiveCompactionFailures = 0; + } catch (error) { + logger?.warn(`PostCompact hooks failed: ${(error as Error).message}`); + } + } - // Log compaction event - logOTelEvent("compaction", { - beforeTokens: String(messagesToCompact.length), - afterTokens: "1", - model: this.getModelConfig().fastModel, - }).catch(() => {}); + logger?.debug( + `Successfully compacted ${messagesToCompact.length} messages`, + ); + } catch (compactError) { + this.consecutiveCompactionFailures++; + logger?.error( + `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`, + compactError, + ); + this.messageManager.addErrorBlock( + `Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`, + ); + } finally { + this.setIsCompacting(false); + } + } - // Run SessionStart hooks after compaction to restore context - if (this.hookManager) { - try { - const newSessionId = this.messageManager.getSessionId(); - const sessionStartResult = - await this.hookManager.executeSessionStartHooks( - "compact", - newSessionId, - this.messageManager.getTranscriptPath(), - this.subagentType, - ); + /** + * Build post-compact context restoration content. + * Restores file reads, working directory, plan mode, skills, and background tasks. + */ + private async buildPostCompactContext(summary: string): Promise { + const POST_COMPACT_TOKEN_BUDGET = 50_000; + const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000; + const POST_COMPACT_MAX_FILES_TO_RESTORE = 5; + const contextParts: string[] = []; + + // 1. File context restoration + const recentFiles = this.messageManager.getRecentFileReads( + POST_COMPACT_MAX_FILES_TO_RESTORE, + POST_COMPACT_MAX_TOKENS_PER_FILE, + ); + let usedTokens = 0; + for (const file of recentFiles) { + const fileTokens = Math.ceil(file.content.length / 4); + if (usedTokens + fileTokens > POST_COMPACT_MAX_TOKENS_PER_FILE) continue; + if (fileTokens > 0) usedTokens += fileTokens; + contextParts.push(`\n\n## ${file.path}\n\`\`\`\n${file.content}\n\`\`\``); + if (contextParts.length >= POST_COMPACT_MAX_FILES_TO_RESTORE) break; + if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break; + } - // Inject additionalContext as a meta user message - if (sessionStartResult.additionalContext) { - this.messageManager.addUserMessage({ - content: `\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n`, - isMeta: true, - }); - } + // 2. Working directory + contextParts.push( + `\n\n[Working Directory]\nCurrent working directory: ${this.getWorkdir()}`, + ); - // Inject initialUserMessage as a meta user message - if (sessionStartResult.initialUserMessage) { - this.messageManager.addUserMessage({ - content: sessionStartResult.initialUserMessage, - isMeta: true, - }); - } - } catch (error) { - logger?.warn( - `SessionStart hooks on compact failed: ${(error as Error).message}`, - ); - } + // 3. Plan mode context + const currentMode = this.permissionManager?.getCurrentEffectiveMode( + this.getModelConfig().permissionMode, + ); + if (currentMode === "plan") { + const planFilePath = this.permissionManager?.getPlanFilePath(); + if (planFilePath) { + let planExists = false; + try { + await fs.access(planFilePath); + planExists = true; + } catch { + // Plan file doesn't exist yet + } + contextParts.push( + `\n\n${buildPlanModeReminder(planFilePath, planExists, !!this.subagentType)}`, + ); + } + } + + // 4. Invoked skills context (with token budget, matching Claude Code) + const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000; + const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000; + const invokedSkillNames = this.messageManager.getInvokedSkillNames(10); + if (invokedSkillNames.length > 0 && this.skillManager) { + const invokedSkillParts: string[] = []; + let skillsUsedTokens = 0; + for (const skillName of invokedSkillNames) { + try { + const skill = await this.skillManager.loadSkill(skillName); + if (!skill) continue; + + const contentMatch = skill.content.match( + /^---\n[\s\S]*?\n---\n([\s\S]*)$/, + ); + let skillContent = contentMatch + ? contentMatch[1].trim() + : skill.content; + + const maxSkillChars = POST_COMPACT_MAX_TOKENS_PER_SKILL * 4; + if (skillContent.length > maxSkillChars) { + skillContent = + skillContent.slice(0, maxSkillChars) + "\n\n...[truncated]..."; } - } catch (compactError) { - this.consecutiveCompactionFailures++; - logger?.error( - `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`, - compactError, + + const skillTokens = Math.ceil(skillContent.length / 4); + if (skillsUsedTokens + skillTokens > POST_COMPACT_SKILLS_TOKEN_BUDGET) + break; + skillsUsedTokens += skillTokens; + + invokedSkillParts.push( + `\n\n## ${skill.name}\n${skill.description ? `*${skill.description}*\n\n` : ""}\`\`\`\n${skillContent}\n\`\`\``, ); - this.messageManager.addErrorBlock( - `Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`, + } catch { + // Skip skills that can't be loaded + } + } + if (invokedSkillParts.length > 0) { + contextParts.push( + `\n\n[Invoked Skills]\n${invokedSkillParts.join("")}`, + ); + } + } + + // 5. Background subagent status (shell tasks excluded, matching Claude Code's createAsyncAgentAttachmentsIfNeeded) + const agents = + this.backgroundTaskManager + ?.getAllTasks() + .filter((a) => a.type === "subagent") || []; + if (agents.length > 0) { + const agentParts: string[] = []; + for (const a of agents) { + if (a.status === "killed") { + agentParts.push( + `Task "${a.description}" (${a.id}) was stopped by the user.`, ); - } finally { - this.setIsCompacting(false); + } else if (a.status === "running") { + const parts = [ + `Background agent "${a.description}" (${a.id}) is still running.`, + `Do NOT spawn a duplicate. You will be notified when it completes.`, + ]; + if (a.outputPath) { + parts.push(`You can read partial output at ${a.outputPath}.`); + } + agentParts.push(parts.join(" ")); + } else { + // completed or failed + const parts = [ + `Task ${a.id} (status: ${a.status}) (description: ${a.description}).`, + ]; + const deltaText = a.status === "failed" ? a.stderr : a.stdout; + if (deltaText && deltaText.length > 0) { + const summary = + deltaText.length > 500 + ? deltaText.slice(0, 500) + "..." + : deltaText; + parts.push(`Delta: ${summary}`); + } + if (a.outputPath) { + parts.push( + `Read the output file to retrieve the result: ${a.outputPath}.`, + ); + } + agentParts.push(parts.join(" ")); } } + if (agentParts.length > 0) { + contextParts.push(`\n\n[Background Tasks]\n${agentParts.join("\n")}`); + } } + + return ( + summary + + (contextParts.length > 0 + ? `\n\n[Context Restoration]` + contextParts.join("") + : "") + ); } public getIsCompacting(): boolean { diff --git a/packages/agent-sdk/src/managers/hookManager.ts b/packages/agent-sdk/src/managers/hookManager.ts index 71f7002e3..82f0c0141 100644 --- a/packages/agent-sdk/src/managers/hookManager.ts +++ b/packages/agent-sdk/src/managers/hookManager.ts @@ -400,6 +400,16 @@ export class HookManager { messageManager.addErrorBlock(errorMessage); return { shouldBlock: false }; + case "PreCompact": + // Non-blocking for compaction, show error in error block + messageManager.addErrorBlock(errorMessage); + return { shouldBlock: false }; + + case "PostCompact": + // Non-blocking for compaction, show error in error block + messageManager.addErrorBlock(errorMessage); + return { shouldBlock: false }; + default: return { shouldBlock: false }; } @@ -606,7 +616,9 @@ export class HookManager { event === "WorktreeCreate" || event === "WorktreeRemove" || event === "SessionStart" || - event === "SessionEnd") && + event === "SessionEnd" || + event === "PreCompact" || + event === "PostCompact") && context.toolName !== undefined ) { logger?.warn( @@ -689,7 +701,9 @@ export class HookManager { event === "WorktreeRemove" || event === "CwdChanged" || event === "SessionStart" || - event === "SessionEnd" + event === "SessionEnd" || + event === "PreCompact" || + event === "PostCompact" ) { return true; } @@ -752,7 +766,9 @@ export class HookManager { event === "WorktreeCreate" || event === "WorktreeRemove" || event === "SessionStart" || - event === "SessionEnd") && + event === "SessionEnd" || + event === "PreCompact" || + event === "PostCompact") && config.matcher ) { errors.push(`${prefix}: Event ${event} should not have a matcher`); @@ -796,6 +812,8 @@ export class HookManager { CwdChanged: 0, SessionStart: 0, SessionEnd: 0, + PreCompact: 0, + PostCompact: 0, }, }; } @@ -812,6 +830,8 @@ export class HookManager { CwdChanged: 0, SessionStart: 0, SessionEnd: 0, + PreCompact: 0, + PostCompact: 0, }; let totalConfigs = 0; @@ -977,4 +997,73 @@ export class HookManager { return results; } + + /** + * Execute PreCompact hooks before compaction. + * Returns custom instructions from hook stdout. + */ + async executePreCompactHooks( + sessionId: string, + transcriptPath: string, + customInstructions?: string, + ): Promise<{ + results: HookExecutionResult[]; + additionalInstructions?: string; + }> { + const context: ExtendedHookExecutionContext = { + event: "PreCompact", + projectDir: this.workdir, + timestamp: new Date(), + sessionId, + transcriptPath, + cwd: this.workdir, + compactInstructions: customInstructions, + env: Object.fromEntries( + Object.entries(process.env).filter((e) => e[1] !== undefined), + ) as Record, + }; + + const results = await this.executeHooks("PreCompact", context); + + let additionalInstructions: string | undefined; + for (const result of results) { + if (result.success && result.stdout?.trim()) { + const trimmed = result.stdout.trim(); + additionalInstructions = additionalInstructions + ? additionalInstructions + "\n" + trimmed + : trimmed; + } + } + + return { results, additionalInstructions }; + } + + /** + * Execute PostCompact hooks after compaction. + * Receives the compact summary text. + */ + async executePostCompactHooks( + sessionId: string, + transcriptPath: string, + compactSummary: string, + ): Promise { + const context: ExtendedHookExecutionContext = { + event: "PostCompact", + projectDir: this.workdir, + timestamp: new Date(), + sessionId, + transcriptPath, + cwd: this.workdir, + compactSummary, + env: + this.container.get>("MergedEnv") || + (process.env as Record), + }; + + const results = await this.executeHooks("PostCompact", context); + if (results.length > 0) { + this.processHookResults("PostCompact", results); + } + return results; + } } diff --git a/packages/agent-sdk/src/managers/slashCommandManager.ts b/packages/agent-sdk/src/managers/slashCommandManager.ts index 0a0566ec3..663b9f3f9 100644 --- a/packages/agent-sdk/src/managers/slashCommandManager.ts +++ b/packages/agent-sdk/src/managers/slashCommandManager.ts @@ -158,6 +158,23 @@ export class SlashCommandManager { } }, }); + + // Register built-in compact command + this.registerCommand({ + id: "compact", + name: "compact", + description: "Compact conversation history to reduce context usage", + handler: async (args?: string, signal?: AbortSignal) => { + this.aiManager.abortAIMessage(); + + const customInstructions = args?.trim() || undefined; + + await this.aiManager.compactConversation({ + customInstructions, + abortSignal: signal, + }); + }, + }); } /** diff --git a/packages/agent-sdk/src/services/aiService.ts b/packages/agent-sdk/src/services/aiService.ts index 3efabc041..d63020c8c 100644 --- a/packages/agent-sdk/src/services/aiService.ts +++ b/packages/agent-sdk/src/services/aiService.ts @@ -758,6 +758,7 @@ export interface CompactMessagesOptions { messages: ChatCompletionMessageParam[]; abortSignal?: AbortSignal; model?: string; + customInstructions?: string; } export interface CompactMessagesResult { @@ -835,7 +836,9 @@ export async function compactMessages( ...cleanedMessages, { role: "user", - content: `Please create a detailed summary of the conversation so far.`, + content: options.customInstructions + ? `Please create a detailed summary of the conversation so far. Pay special attention to these instructions: ${options.customInstructions}` + : `Please create a detailed summary of the conversation so far.`, }, ], }, diff --git a/packages/agent-sdk/src/types/hooks.ts b/packages/agent-sdk/src/types/hooks.ts index e1295cf9a..b509e33f9 100644 --- a/packages/agent-sdk/src/types/hooks.ts +++ b/packages/agent-sdk/src/types/hooks.ts @@ -24,7 +24,9 @@ export type HookEvent = | "WorktreeRemove" | "CwdChanged" | "SessionStart" - | "SessionEnd"; + | "SessionEnd" + | "PreCompact" + | "PostCompact"; // Individual hook command configuration export interface HookCommand { @@ -117,6 +119,8 @@ export function isValidHookEvent(event: string): event is HookEvent { "CwdChanged", "SessionStart", "SessionEnd", + "PreCompact", + "PostCompact", ].includes(event); } @@ -187,6 +191,8 @@ export interface HookJsonInput { source?: SessionStartSource; // Present for SessionStart events agent_type?: string; // Present for SessionStart events end_source?: SessionEndSource; // Present for SessionEnd events + compact_instructions?: string; // Present for PreCompact events + compact_summary?: string; // Present for PostCompact events } // Extended context interface for passing additional data to hook executor @@ -205,6 +211,8 @@ export interface ExtendedHookExecutionContext extends HookExecutionContext { source?: SessionStartSource; // Session start source (SessionStart only) agentType?: string; // Agent type identifier (SessionStart only) endSource?: SessionEndSource; // Session end source (SessionEnd only) + compactInstructions?: string; // Custom instructions for PreCompact + compactSummary?: string; // Summary text for PostCompact } // Environment variables injected into hook processes diff --git a/packages/agent-sdk/tests/managers/aiManager.compactConversation.test.ts b/packages/agent-sdk/tests/managers/aiManager.compactConversation.test.ts new file mode 100644 index 000000000..b9749d1c8 --- /dev/null +++ b/packages/agent-sdk/tests/managers/aiManager.compactConversation.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Container } from "../../src/utils/container.js"; +import { AIManager } from "../../src/managers/aiManager.js"; +import type { MessageManager } from "../../src/managers/messageManager.js"; +import type { ToolManager } from "../../src/managers/toolManager.js"; +import type { PermissionManager } from "../../src/managers/permissionManager.js"; +import type { HookManager } from "../../src/managers/hookManager.js"; +import type { GatewayConfig, ModelConfig } from "../../src/types/index.js"; + +const { compactMessagesMock } = vi.hoisted(() => ({ + compactMessagesMock: vi.fn().mockResolvedValue({ + content: "Compacted content", + usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 }, + }), +})); + +vi.mock("../../src/utils/globalLogger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + default: { access: vi.fn() }, +})); + +vi.mock("../../src/utils/gitUtils.js", () => ({ + isGitRepository: vi.fn(), +})); + +vi.mock("../../src/services/aiService.js", () => ({ + callAgent: vi.fn().mockImplementation(async (options) => { + if (options.onContentUpdate) options.onContentUpdate("Test response"); + return { + content: "Test response", + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + tool_calls: [], + }; + }), + compactMessages: compactMessagesMock, + isClaudeModel: vi.fn().mockReturnValue(false), + transformMessagesForClaudeCache: vi.fn((m) => m), + addCacheControlToLastTool: vi.fn((t) => t), + extendUsageWithCacheMetrics: vi.fn((u) => u), +})); + +vi.mock("../../src/services/memory.js", () => ({ + MemoryService: vi.fn().mockImplementation(() => ({ + getCombinedMemoryContent: vi.fn().mockResolvedValue(""), + getAutoMemoryDirectory: vi.fn().mockReturnValue("/mock/auto-memory"), + ensureAutoMemoryDirectory: vi.fn().mockResolvedValue(undefined), + getAutoMemoryContent: vi.fn().mockResolvedValue(""), + })), + getCombinedMemoryContent: vi.fn().mockResolvedValue(""), +})); + +vi.mock("../../src/utils/messageOperations.js", () => ({})); + +vi.mock("../../src/utils/convertMessagesForAPI.js", () => ({ + convertMessagesForAPI: vi + .fn() + .mockReturnValue([{ role: "user", content: "hello" }]), +})); + +vi.mock("../../src/telemetry/events.js", () => ({ + logOTelEvent: vi.fn().mockResolvedValue(undefined), +})); + +describe("AIManager - compactConversation", () => { + let aiManager: AIManager; + let mockMessageManager: MessageManager; + let mockHookManager: HookManager; + + const mockGatewayConfig: GatewayConfig = { + apiKey: "test-api-key", + baseURL: "https://test-gateway.com", + }; + + const mockModelConfig: ModelConfig = { + model: "test-agent-model", + fastModel: "test-fast-model", + maxTokens: 4096, + permissionMode: "default", + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Re-establish mock implementations after clearAllMocks + compactMessagesMock.mockResolvedValue({ + content: "Compacted content", + usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 }, + }); + + const container = new Container(); + + mockMessageManager = { + getMessages: vi.fn().mockReturnValue([{ role: "user", blocks: [] }]), + getSessionId: vi.fn().mockReturnValue("test-session-id"), + getTranscriptPath: vi.fn().mockReturnValue("/test/transcript.json"), + saveSession: vi.fn().mockResolvedValue(undefined), + compactMessagesAndUpdateSession: vi.fn(), + addErrorBlock: vi.fn(), + addUserMessage: vi.fn(), + getRecentFileReads: vi.fn().mockReturnValue([]), + getInvokedSkillNames: vi.fn().mockReturnValue([]), + setlatestTotalTokens: vi.fn(), + } as unknown as MessageManager; + + mockHookManager = { + executePreCompactHooks: vi.fn().mockResolvedValue({ + results: [], + additionalInstructions: undefined, + }), + executePostCompactHooks: vi.fn().mockResolvedValue([]), + executeSessionStartHooks: vi.fn().mockResolvedValue({ + results: [], + additionalContext: undefined, + initialUserMessage: undefined, + }), + } as unknown as HookManager; + + const mockToolManager = { + list: vi.fn().mockReturnValue([]), + get: vi.fn(), + } as unknown as ToolManager; + + const mockPermissionManager = { + getCurrentEffectiveMode: vi.fn().mockReturnValue("default"), + getPlanFilePath: vi.fn().mockReturnValue(undefined), + } as unknown as PermissionManager; + + container.register("MessageManager", mockMessageManager); + container.register("ToolManager", mockToolManager); + container.register("PermissionManager", mockPermissionManager); + container.register("HookManager", mockHookManager); + container.register("BackgroundTaskManager", { + getAllTasks: vi.fn().mockReturnValue([]), + }); + container.register("SubagentManager", {}); + container.register("SkillManager", undefined); + container.register("MemoryService", { + getCombinedMemoryContent: vi.fn().mockResolvedValue(""), + getAutoMemoryDirectory: vi.fn().mockReturnValue("/mock/auto-memory"), + ensureAutoMemoryDirectory: vi.fn().mockResolvedValue(undefined), + getAutoMemoryContent: vi.fn().mockResolvedValue(""), + }); + container.register("TaskManager", { syncWithSession: vi.fn() }); + container.register("MergedEnv", { PATH: "/usr/bin" }); + container.register("ConfigurationService", { + resolveGatewayConfig: vi.fn().mockReturnValue(mockGatewayConfig), + resolveModelConfig: vi.fn().mockReturnValue(mockModelConfig), + resolveMaxInputTokens: vi.fn().mockReturnValue(96000), + }); + + aiManager = new AIManager(container, { + workdir: "/test/workdir", + stream: false, + callbacks: { + onCompactionStateChange: vi.fn(), + onUsageAdded: vi.fn(), + }, + }); + }); + + it("should call compactMessages and compactMessagesAndUpdateSession", async () => { + await aiManager.compactConversation(); + + expect(compactMessagesMock).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayConfig: mockGatewayConfig, + modelConfig: mockModelConfig, + model: "test-fast-model", + }), + ); + expect( + mockMessageManager.compactMessagesAndUpdateSession, + ).toHaveBeenCalled(); + }); + + it("should pass custom instructions to compactMessages", async () => { + await aiManager.compactConversation({ + customInstructions: "Focus on the bug fix discussion", + }); + + expect(compactMessagesMock).toHaveBeenCalledWith( + expect.objectContaining({ + customInstructions: "Focus on the bug fix discussion", + }), + ); + }); + + it("should fire PreCompact hooks before compaction", async () => { + await aiManager.compactConversation({ + customInstructions: "user instructions", + }); + + expect(mockHookManager.executePreCompactHooks).toHaveBeenCalledWith( + "test-session-id", + "/test/transcript.json", + "user instructions", + ); + }); + + it("should merge PreCompact hook stdout into custom instructions", async () => { + vi.mocked(mockHookManager.executePreCompactHooks).mockResolvedValueOnce({ + results: [], + additionalInstructions: "hook instructions", + }); + + await aiManager.compactConversation({ + customInstructions: "user instructions", + }); + + expect(compactMessagesMock).toHaveBeenCalledWith( + expect.objectContaining({ + customInstructions: "user instructions\nhook instructions", + }), + ); + }); + + it("should fire PostCompact hooks after compaction", async () => { + await aiManager.compactConversation(); + + expect(mockHookManager.executePostCompactHooks).toHaveBeenCalledWith( + "test-session-id", + "/test/transcript.json", + "Compacted content", + ); + }); + + it("should fire SessionStart hooks with source='compact'", async () => { + await aiManager.compactConversation(); + + expect(mockHookManager.executeSessionStartHooks).toHaveBeenCalledWith( + "compact", + "test-session-id", + "/test/transcript.json", + undefined, + ); + }); + + it("should skip if already compacting", async () => { + let resolveFirst: () => void; + const firstCompact = new Promise( + (resolve) => (resolveFirst = resolve), + ); + compactMessagesMock.mockImplementationOnce(async () => { + await firstCompact; + return { content: "compacted", usage: undefined }; + }); + + const firstCall = aiManager.compactConversation(); + + // Wait for the first compaction to actually start (isCompacting = true) + await vi.waitFor(() => { + expect(aiManager.getIsCompacting()).toBe(true); + }); + + // Try a second compaction while the first is running - should be skipped + await aiManager.compactConversation(); + + // Only one compactMessages call should have happened + expect(compactMessagesMock).toHaveBeenCalledTimes(1); + + resolveFirst!(); + await firstCall; + }); + + it("should return early when messages are empty", async () => { + vi.mocked(mockMessageManager.getMessages).mockReturnValueOnce([]); + + await aiManager.compactConversation(); + + expect(compactMessagesMock).not.toHaveBeenCalled(); + }); + + it("should increment consecutiveCompactionFailures and add error block on failure", async () => { + compactMessagesMock.mockRejectedValueOnce(new Error("API error")); + + await aiManager.compactConversation(); + + expect(mockMessageManager.addErrorBlock).toHaveBeenCalledWith( + expect.stringContaining("Failed to compact conversation history"), + ); + }); +}); diff --git a/specs/005-hooks/contracts/hooks-api.md b/specs/005-hooks/contracts/hooks-api.md index 785741084..200e3e5de 100644 --- a/specs/005-hooks/contracts/hooks-api.md +++ b/specs/005-hooks/contracts/hooks-api.md @@ -11,7 +11,7 @@ ```typescript // types/hooks.ts // Hook event types -type HookEvent = 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'Stop' | 'PermissionRequest' | 'SubagentStop' | 'WorktreeCreate'; +type HookEvent = 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'Stop' | 'PermissionRequest' | 'SubagentStop' | 'WorktreeCreate' | 'CwdChanged' | 'SessionStart' | 'SessionEnd' | 'PreCompact' | 'PostCompact'; // Individual hook command interface HookCommand { @@ -56,6 +56,8 @@ interface ExtendedHookExecutionContext extends HookExecutionContext { toolInput?: unknown; toolResponse?: unknown; userPrompt?: string; + compactInstructions?: string; // PreCompact + compactSummary?: string; // PostCompact } // JSON data structure passed to hook processes via stdin @@ -70,6 +72,8 @@ interface HookJsonInput { prompt?: string; subagent_type?: string; name?: string; + compact_instructions?: string; // PreCompact + compact_summary?: string; // PostCompact } // Result of hook execution @@ -106,6 +110,15 @@ class HookManager { // Validate hook configuration validateConfiguration(config: HookConfiguration): ValidationResult; + + // Execute PreCompact hooks before compaction + executePreCompactHooks(sessionId: string, transcriptPath: string, customInstructions?: string): Promise<{ + results: HookExecutionResult[]; + additionalInstructions?: string; + }>; + + // Execute PostCompact hooks after compaction + executePostCompactHooks(sessionId: string, transcriptPath: string, compactSummary: string): Promise; } interface ValidationResult { diff --git a/specs/005-hooks/data-model.md b/specs/005-hooks/data-model.md index b9f4ebd44..5c7a89ff7 100644 --- a/specs/005-hooks/data-model.md +++ b/specs/005-hooks/data-model.md @@ -82,6 +82,11 @@ - `PermissionRequest` - Triggered when Wave requests permission to use a tool - `SubagentStop` - Triggered when a subagent finishes its response cycle - `WorktreeCreate` - Triggered when a new worktree is created +- `CwdChanged` - Triggered when the current working directory changes +- `SessionStart` - Triggered when a new session starts (startup, compact, clear) +- `SessionEnd` - Triggered when a session ends +- `PreCompact` - Triggered before conversation compaction +- `PostCompact` - Triggered after successful conversation compaction **Validation Rules**: Must be one of the four defined values @@ -141,6 +146,8 @@ - `prompt?: string` - User prompt text (UserPromptSubmit) - `subagent_type?: string` - Subagent type when hook is executed by a subagent - `name?: string` - Worktree name (WorktreeCreate) +- `compact_instructions?: string` - Custom instructions for compaction (PreCompact) +- `compact_summary?: string` - AI-generated compact summary (PostCompact) **Validation Rules**: - session_id must be non-empty @@ -158,7 +165,7 @@ Represents the processing context for interpreting hook execution results. **Fields**: -- `event: HookEvent` - The hook event type (PreToolUse, PostToolUse, UserPromptSubmit, Stop) +- `event: HookEvent` - The hook event type (PreToolUse, PostToolUse, UserPromptSubmit, Stop, PreCompact, PostCompact) - `exitCode: number` - The hook process exit code - `stdout: string` - Hook standard output - `stderr: string` - Hook standard error @@ -336,7 +343,7 @@ interface HookJsonInput { session_id: string; // Unique session identifier transcript_path: string; // Absolute path to session file cwd: string; // Current working directory - hook_event_name: HookEvent; // "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop" | "PermissionRequest" | "SubagentStop" | "WorktreeCreate" + hook_event_name: HookEvent; // "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop" | "PermissionRequest" | "SubagentStop" | "WorktreeCreate" | "CwdChanged" | "SessionStart" | "SessionEnd" | "PreCompact" | "PostCompact" // Event-specific fields (optional, based on event type) tool_name?: string; @@ -345,6 +352,8 @@ interface HookJsonInput { prompt?: string; subagent_type?: string; name?: string; + compact_instructions?: string; + compact_summary?: string; } ``` @@ -435,6 +444,8 @@ export interface HookJsonInput { tool_input?: unknown; tool_response?: unknown; prompt?: string; + compact_instructions?: string; // PreCompact + compact_summary?: string; // PostCompact } // Extended execution context for internal use @@ -443,6 +454,8 @@ export interface ExtendedHookExecutionContext extends HookExecutionContext { toolInput?: unknown; toolResponse?: unknown; userPrompt?: string; + compactInstructions?: string; + compactSummary?: string; } // JSON construction helper diff --git a/specs/005-hooks/quickstart.md b/specs/005-hooks/quickstart.md index 4949960ee..b23ae045a 100644 --- a/specs/005-hooks/quickstart.md +++ b/specs/005-hooks/quickstart.md @@ -151,6 +151,12 @@ exit 1 | **PreToolUse** | continue execution | stderr → agent, block tool | stderr → user | | **PostToolUse** | continue execution | stderr → agent (tool ran) | stderr → user | | **Stop** | allow stop | stderr → agent, block stop | stderr → user | +| **PreCompact** | continue execution | stderr → user (non-blocking) | stderr → user | +| **PostCompact** | continue execution | stderr → user (non-blocking) | stderr → user | +| **PreCompact** | continue execution (stdout → additional instructions) | stderr → user (non-blocking) | stderr → user | +| **PostCompact** | continue execution | stderr → user (non-blocking) | stderr → user | +| **PreCompact** | stdout → merged into compact instructions | stderr → user (non-blocking) | stderr → user | +| **PostCompact** | continue execution | stderr → user (non-blocking) | stderr → user | ### Implementation Integration diff --git a/specs/005-hooks/spec.md b/specs/005-hooks/spec.md index a83408f0a..3990168df 100644 --- a/specs/005-hooks/spec.md +++ b/specs/005-hooks/spec.md @@ -31,6 +31,8 @@ Hooks communicate status through exit codes, stdout, and stderr: | `SubagentStop` | Blocks stoppage (Subagent continues), shows stderr to Wave | | `PermissionRequest`| Blocks (denies) permission, shows stderr to user only | | `WorktreeCreate` | Shows stderr to user only (non-blocking) | +| `PreCompact` | Shows stderr to user only (non-blocking) | +| `PostCompact` | Shows stderr to user only (non-blocking) | ## User Scenarios & Testing *(mandatory)* @@ -248,6 +250,38 @@ As an SDK user, I want to inject hook configuration programmatically via `Agent. 2. **Given** an `Agent.create()` call without `hooks` option, **When** the agent is created, **Then** the HookManager has no programmatic hooks and `hookManager.hasHooks("Stop")` returns false 3. **Given** both `AgentOptions.hooks` and file-based hooks configure the same event (e.g., Stop), **When** the agent is created, **Then** both programmatic and file-based hooks coexist and all execute in order (programmatic first, then file-based) +--- + +### User Story 15 - PreCompact Hook for Compaction Customization (Priority: P2) + +As a developer, I want to run hooks before conversation compaction occurs, so that I can inject custom instructions to guide the summarization or perform pre-compaction actions. + +**Why this priority**: Enables customization of the compaction process, allowing teams to ensure specific information is preserved during summarization. + +**Independent Test**: Configure a PreCompact hook that outputs instructions via stdout, trigger compaction, and verify the instructions are included in the summarization prompt. + +**Acceptance Scenarios**: + +1. **Given** a PreCompact hook is configured, **When** compaction is triggered (auto or manual), **Then** the hook executes before the summarization API call +2. **Given** a PreCompact hook outputs text to stdout, **When** compaction runs, **Then** the stdout content is merged with any user-provided custom instructions and passed to the summarization prompt +3. **Given** a PreCompact hook fails, **When** compaction runs, **Then** the failure is logged but does not prevent compaction from proceeding + +--- + +### User Story 16 - PostCompact Hook for Post-Compaction Actions (Priority: P2) + +As a developer, I want to run hooks after conversation compaction completes, so that I can perform post-compaction actions such as logging, notifications, or state synchronization. + +**Why this priority**: Enables downstream systems to react to compaction events, useful for audit trails and external state management. + +**Independent Test**: Configure a PostCompact hook, trigger compaction, and verify the hook receives the compact summary text. + +**Acceptance Scenarios**: + +1. **Given** a PostCompact hook is configured, **When** compaction completes successfully, **Then** the hook executes with the compact summary in the JSON input +2. **Given** a PostCompact hook fails, **When** compaction runs, **Then** the failure is logged but does not affect the compaction result +3. **Given** compaction fails, **When** the error is handled, **Then** PostCompact hooks are NOT executed + ## Edge Cases - What happens when a hook command fails or times out? @@ -316,6 +350,13 @@ As an SDK user, I want to inject hook configuration programmatically via `Agent. - **FR-053**: System MUST support `hooks` option in `AgentOptions` to inject hook configuration programmatically at `Agent.create()` time - **FR-054**: System MUST concatenate hooks from `AgentOptions.hooks` with file-based hooks, so that both programmatic and file-based hooks coexist for the same event - **FR-055**: System MUST validate hooks provided via `AgentOptions.hooks` using the same validation rules as file-based hook configuration +- **FR-056**: System MUST support PreCompact hooks that execute before conversation compaction +- **FR-057**: System MUST merge PreCompact hook stdout with user-provided custom instructions and pass them to the compaction summarization prompt +- **FR-058**: System MUST support PostCompact hooks that execute after successful conversation compaction +- **FR-059**: System MUST include `compact_instructions` field in JSON data for PreCompact events containing any custom instructions +- **FR-060**: System MUST include `compact_summary` field in JSON data for PostCompact events containing the AI-generated summary +- **FR-061**: System MUST NOT execute PostCompact hooks when compaction fails +- **FR-062**: System MUST NOT require a matcher for PreCompact or PostCompact hook configurations ### Testing Validation Requirements @@ -329,7 +370,7 @@ As an SDK user, I want to inject hook configuration programmatically via `Agent. ### Key Entities - **Hook Configuration**: Settings structure containing event mappings, matchers, and command definitions -- **Hook Event**: Specific trigger points in Wave's execution cycle (PreToolUse, PostToolUse, UserPromptSubmit, Stop, PermissionRequest, SubagentStop, WorktreeCreate) +- **Hook Event**: Specific trigger points in Wave's execution cycle (PreToolUse, PostToolUse, UserPromptSubmit, Stop, PermissionRequest, SubagentStop, WorktreeCreate, PreCompact, PostCompact) - **Hook Matcher**: Pattern matching system for determining which hooks apply to specific tool operations (located in utils/hookMatcher.ts) - **Hook Executor**: Function-based service for executing hook commands (located in services/hook.ts) - **Hook Settings**: Service for loading and merging hook configurations (located in services/hook.ts) @@ -343,6 +384,9 @@ As an SDK user, I want to inject hook configuration programmatically via `Agent. - **ErrorBlock**: Data structure in assistant messages that contains user-visible error information for UserPromptSubmit hooks, excluded from API conversion - **Agent Message Collection**: The `agent.messages` array that serves as the primary validation point for testing hook behavior correctness - **Programmatic Hook Configuration**: `AgentOptions.hooks` field of type `PartialHookConfiguration` that allows SDK users to inject hooks at creation time, supplementing file-based configuration +- **PreCompact Hook**: Lifecycle hook that fires before conversation compaction, receiving custom instructions via stdin JSON and returning additional instructions via stdout +- **PostCompact Hook**: Lifecycle hook that fires after successful compaction, receiving the compact summary via stdin JSON +- **Compact Instructions**: Custom text that guides the AI summarization during compaction, merged from user input and PreCompact hook stdout ## Success Criteria *(mandatory)* diff --git a/specs/005-hooks/tasks.md b/specs/005-hooks/tasks.md index f55ad7b78..0110ce4ec 100644 --- a/specs/005-hooks/tasks.md +++ b/specs/005-hooks/tasks.md @@ -355,6 +355,51 @@ --- +## Phase 22: User Story 15 & 16 - PreCompact/PostCompact Hook Events (Priority: P2) + +**Goal**: Add PreCompact and PostCompact hook events that fire before/after conversation compaction + +**Independent Test**: Configure PreCompact/PostCompact hooks, trigger compaction, verify hooks receive correct context + +### Tests for PreCompact/PostCompact + +- [x] T110 [P] [US15] Write tests for PreCompact hook execution in `packages/agent-sdk/tests/managers/aiManager.compactConversation.test.ts` +- [x] T111 [P] [US16] Write tests for PostCompact hook execution in same file + +### Implementation for PreCompact/PostCompact + +- [x] T112 [US15] Add PreCompact and PostCompact to HookEvent union and isValidHookEvent in `packages/agent-sdk/src/types/hooks.ts` +- [x] T113 [US15] Add compactInstructions and compactSummary fields to HookJsonInput and ExtendedHookExecutionContext in `packages/agent-sdk/src/types/hooks.ts` +- [x] T114 [US15] Add PreCompact/PostCompact to configApplies, validateEventConfig, validateExecutionContext, handleBlockingError, getConfigurationStats in `packages/agent-sdk/src/managers/hookManager.ts` +- [x] T115 [US15] Add executePreCompactHooks() convenience method to HookManager +- [x] T116 [US16] Add executePostCompactHooks() convenience method to HookManager +- [x] T117 [US15] Wire PreCompact hooks into compactConversation() in `packages/agent-sdk/src/managers/aiManager.ts` +- [x] T118 [US16] Wire PostCompact hooks into compactConversation() in `packages/agent-sdk/src/managers/aiManager.ts` + +**Checkpoint**: PreCompact and PostCompact hook events are fully functional + +--- + +## Phase 22: User Story 15 - PreCompact and PostCompact Hook Events (Priority: P2) + +**Goal**: Enable hooks before and after conversation compaction + +**Independent Test**: Configure PreCompact/PostCompact hooks, trigger compaction, verify execution order + +### Implementation for User Story 15 + +- [X] T110 [US15] Add PreCompact and PostCompact to HookEvent type in packages/agent-sdk/src/types/hooks.ts +- [X] T111 [US15] Add compactInstructions and compactSummary to ExtendedHookExecutionContext and HookJsonInput in packages/agent-sdk/src/types/hooks.ts +- [X] T112 [US15] Update HookManager for PreCompact/PostCompact events (configApplies, validateEventConfig, handleBlockingError, getConfigurationStats) in packages/agent-sdk/src/managers/hookManager.ts +- [X] T113 [US15] Add executePreCompactHooks() and executePostCompactHooks() convenience methods in packages/agent-sdk/src/managers/hookManager.ts +- [X] T114 [US15] Add customInstructions to CompactMessagesOptions in packages/agent-sdk/src/services/aiService.ts +- [X] T115 [US15] Extract compactConversation() public method and buildPostCompactContext() private helper in packages/agent-sdk/src/managers/aiManager.ts +- [X] T116 [US15] Refactor handleTokenUsageAndCompaction() to delegate to compactConversation() in packages/agent-sdk/src/managers/aiManager.ts +- [X] T117 [US15] Add /compact slash command handler in packages/agent-sdk/src/managers/slashCommandManager.ts +- [X] T118 [US15] Add tests for compactConversation in packages/agent-sdk/tests/managers/aiManager.compactConversation.test.ts + +--- + ## Dependencies & Execution Order ### Phase Dependencies diff --git a/specs/014-message-compression/claude-code-compact-logic.md b/specs/014-message-compact/claude-code-compact-logic.md similarity index 100% rename from specs/014-message-compression/claude-code-compact-logic.md rename to specs/014-message-compact/claude-code-compact-logic.md diff --git a/specs/014-message-compact/data-model.md b/specs/014-message-compact/data-model.md new file mode 100644 index 000000000..f503f4209 --- /dev/null +++ b/specs/014-message-compact/data-model.md @@ -0,0 +1,61 @@ +# Data Model: Message Compact + +## Entities + +### CompressBlock +Used in **user** messages to store the summary of compacted history (matching Claude Code's auto-compact behavior). + +| Field | Type | Description | +|-------|------|-------------| +| `type` | 'compress' | Identifies the block as a compaction summary. | +| `content` | string | The summary text generated by the AI, augmented with `[Context Restoration]` section. | + +### CompactConversationOptions +Options for the public `compactConversation()` method on AIManager. + +| Field | Type | Description | +|-------|------|-------------| +| `customInstructions?` | string | Optional custom instructions to guide summarization (from `/compact [args]`). | +| `abortSignal?` | AbortSignal | Optional abort signal for cancelling compaction. | + +### PreCompact Hook Context +Context provided to PreCompact hook events. + +| Field | Type | Description | +|-------|------|-------------| +| `event` | 'PreCompact' | Hook event type. | +| `compact_instructions?` | string | Custom instructions from user input (via `/compact [args]`). | +| `session_id` | string | Current session identifier. | +| `transcript_path` | string | Path to session transcript. | + +### PostCompact Hook Context +Context provided to PostCompact hook events. + +| Field | Type | Description | +|-------|------|-------------| +| `event` | 'PostCompact' | Hook event type. | +| `compact_summary` | string | The AI-generated compaction summary (before context restoration). | +| `session_id` | string | Current session identifier. | +| `transcript_path` | string | Path to session transcript. | + +## State Transitions + +### History Compaction (Auto) +1. **Monitoring**: `AIManager` checks token usage after each response. +2. **Triggered**: Usage exceeds threshold; all current messages are selected for compaction. +3. **PreCompact Hooks**: Execute before summarization; stdout merged as additional instructions. +4. **Summarizing**: AI generates a continuation summary of the conversation (with optional custom instructions). +5. **Context Restoration**: Summary augmented with recent files, working directory, plan mode, skills, background tasks. +6. **Compacted**: Entire history is replaced by a single `CompressBlock` and a new session ID is generated. +7. **SessionStart Hooks**: Fire with source="compact" for context restoration. +8. **PostCompact Hooks**: Execute after compaction with the summary text. + +### History Compaction (Manual `/compact`) +1. **User Trigger**: User types `/compact` or `/compact [custom instructions]`. +2. **Abort AI**: Any running AI response is aborted. +3. **PreCompact Hooks**: Execute before summarization. +4. **Summarizing**: AI generates summary with merged custom instructions. +5. **Context Restoration**: Same as auto-compaction. +6. **Compacted**: Same as auto-compaction. +7. **SessionStart Hooks**: Same as auto-compaction. +8. **PostCompact Hooks**: Same as auto-compaction. diff --git a/specs/014-message-compression/plan.md b/specs/014-message-compact/plan.md similarity index 62% rename from specs/014-message-compression/plan.md rename to specs/014-message-compact/plan.md index 43f64c84c..a5074ca03 100644 --- a/specs/014-message-compression/plan.md +++ b/specs/014-message-compact/plan.md @@ -1,11 +1,11 @@ -# Implementation Plan: Message Compression +# Implementation Plan: Message Compact -**Branch**: `014-message-compression` | **Date**: 2026-01-22 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/014-message-compression/spec.md` +**Branch**: `014-message-compact` | **Date**: 2026-01-22 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/014-message-compact/spec.md` ## Summary -Implement a history compression system in `agent-sdk` to manage token limits by automatically summarizing older messages. +Implement a history compact system in `agent-sdk` to manage token limits by automatically summarizing older messages. Includes a `/compact` slash command for manual compaction with optional custom instructions, and PreCompact/PostCompact hook events. ## Technical Context @@ -16,14 +16,14 @@ Implement a history compression system in `agent-sdk` to manage token limits by **Target Platform**: CLI (Node.js) **Project Type**: Monorepo (agent-sdk + code) **Performance Goals**: Efficient summarization -**Constraints**: Full history replacement for history compression +**Constraints**: Full history replacement for history compaction **Scale/Scope**: Core resource management for all agent sessions ## Constitution Check -- [x] **Package-First Architecture**: History compression in `agent-sdk`. -- [x] **TypeScript Excellence**: Strict typing for `compress` blocks. -- [x] **Test Alignment**: Unit tests for compression logic. +- [x] **Package-First Architecture**: History compaction in `agent-sdk`. +- [x] **TypeScript Excellence**: Strict typing for `compress` blocks, `compactConversation()` public API, custom instructions support. +- [x] **Test Alignment**: Unit tests for compaction logic, compactConversation, PreCompact/PostCompact hooks. - [x] **Build Dependencies**: `pnpm build` required for `agent-sdk` changes. - [x] **Quality Gates**: `type-check` and `lint` must pass. - [x] **Test-Driven Development**: Write failing tests for token threshold detection first. @@ -35,7 +35,7 @@ Implement a history compression system in `agent-sdk` to manage token limits by ### Documentation (this feature) ``` -specs/014-message-compression/ +specs/014-message-compact/ ├── plan.md # This file ├── research.md # Phase 0 output ├── data-model.md # Phase 1 output @@ -50,10 +50,11 @@ specs/014-message-compression/ packages/agent-sdk/ ├── src/ │ ├── managers/ -│ │ ├── aiManager.ts # Trigger history compression, microcompact, circuit breaker -│ │ └── messageManager.ts # Compress messages, track file reads, API-round grouping +│ │ ├── aiManager.ts # compactConversation(), buildPostCompactContext(), circuit breaker +│ │ ├── slashCommandManager.ts # /compact built-in command +│ │ └── messageManager.ts # Compress messages, track file reads, API-round grouping │ ├── services/ -│ │ └── aiService.ts # Compress API call, image stripping +│ │ └── aiService.ts # Compact API call, custom instructions, image stripping │ ├── types/ │ │ └── messaging.ts # ToolBlock timestamp field │ ├── prompts/ @@ -69,7 +70,8 @@ packages/agent-sdk/ ├── integration/ │ └── compactionFlow.test.ts # Full pipeline integration tests ├── managers/ - │ └── messageManager.coverage.test.ts + │ ├── messageManager.coverage.test.ts + │ └── aiManager.compactConversation.test.ts # compactConversation tests └── utils/ ├── groupMessagesByApiRound.test.ts └── microcompact.test.ts diff --git a/specs/014-message-compact/quickstart.md b/specs/014-message-compact/quickstart.md new file mode 100644 index 000000000..3282a2522 --- /dev/null +++ b/specs/014-message-compact/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: Message Compact + +## Overview +This feature manages conversation history through automatic summarization and manual `/compact` command. + +## Development Setup +1. Build the `agent-sdk` to include compaction utilities: + ```bash + pnpm -F wave-agent-sdk build + ``` + +## Verification Steps + +### Unit Tests +Run tests for history compaction: +```bash +pnpm -F wave-agent-sdk test tests/managers/aiManager.compactConversation.test.ts +``` + +### Manual Verification + +#### History Compaction +1. Set a low `maxInputTokens` in the agent config or mock token usage. +2. Engage in a long conversation. +3. Verify that the entire conversation history is replaced by a single continuation summary block. +4. Verify the agent still remembers the general context of the summarized messages. + +#### Manual /compact Command +1. Type `/compact` in the conversation to manually trigger compaction. +2. Optionally provide custom instructions: `/compact focus on the API changes`. +3. Verify the conversation history is replaced by a summary that reflects the custom instructions. + +#### PreCompact/PostCompact Hooks +1. Configure a PreCompact hook in `.wave/settings.json`: + ```json + { "hooks": { "PreCompact": [{ "hooks": [{ "type": "command", "command": "echo 'Preserve all error messages'" }] }] } } + ``` +2. Trigger compaction via `/compact`. +3. Verify the hook output is merged into the custom instructions for the summary. diff --git a/specs/014-message-compression/research.md b/specs/014-message-compact/research.md similarity index 97% rename from specs/014-message-compression/research.md rename to specs/014-message-compact/research.md index 1caccaa90..118c49697 100644 --- a/specs/014-message-compression/research.md +++ b/specs/014-message-compact/research.md @@ -1,4 +1,4 @@ -# Research: Message Compression +# Research: Message Compact **Decision**: Implement history compression via AI summarization. diff --git a/specs/014-message-compression/spec.md b/specs/014-message-compact/spec.md similarity index 60% rename from specs/014-message-compression/spec.md rename to specs/014-message-compact/spec.md index 95c6856b2..fde8a5cc6 100644 --- a/specs/014-message-compression/spec.md +++ b/specs/014-message-compact/spec.md @@ -1,22 +1,22 @@ -# Feature Specification: Message Compression +# Feature Specification: Message Compact -**Feature Branch**: `014-message-compression` +**Feature Branch**: `014-message-compact` **Created**: 2026-01-22 **Input**: User description: "Manage conversation history and user input size" ## User Scenarios & Testing *(mandatory)* -### User Story 1 - Automatic History Compression (Priority: P1) +### User Story 1 - Automatic History Compact (Priority: P1) As an AI agent, when the conversation history becomes too long, I want to automatically summarize older messages so that I stay within the model's token limits while maintaining context. **Why this priority**: Essential for long-running sessions to prevent "context window exceeded" errors and reduce costs. -**Independent Test**: Mock token usage to exceed the threshold and verify that `AIManager` triggers a compression cycle and replaces old messages with a summary block. +**Independent Test**: Mock token usage to exceed the threshold and verify that `AIManager` triggers a compact cycle and replaces old messages with a summary block. **Acceptance Scenarios**: -1. **Given** the total token count exceeds `getMaxInputTokens()`, **When** the next message is processed, **Then** the agent MUST identify messages to compress. +1. **Given** the total token count exceeds `getMaxInputTokens()`, **When** the next message is processed, **Then** the agent MUST identify messages to compact. 2. **Given** messages are identified for compression, **When** the summarization is complete, **Then** the original messages MUST be replaced by a `compress` block followed by the last 2 API rounds of the old message list in the session. 3. **Given** a `compress` block exists, **When** sending messages to the API, **Then** it MUST be converted to a **user** message (matching Claude Code's auto-compact behavior). @@ -38,26 +38,26 @@ As an AI agent, before each API call, I want to clear old tool result content th --- -### User Story 3 - Compression Circuit Breaker (Priority: P2) +### User Story 3 - Compact Circuit Breaker (Priority: P2) -As an AI agent, when compression repeatedly fails, I want to stop attempting compression so that I avoid wasting API calls on an irrecoverable context state. +As an AI agent, when compact repeatedly fails, I want to stop attempting compact so that I avoid wasting API calls on an irrecoverable context state. **Why this priority**: Prevents cascading failures and unnecessary API costs when the context is corrupted. -**Independent Test**: Simulate 3 consecutive compression failures and verify that the 4th high-token-usage turn skips compression entirely. +**Independent Test**: Simulate 3 consecutive compact failures and verify that the 4th high-token-usage turn skips compact entirely. **Acceptance Scenarios**: -1. **Given** compression has failed 3 consecutive times, **When** token usage again exceeds the threshold, **Then** compression MUST be skipped and a warning logged. -2. **Given** compression succeeds, **When** the next compression cycle runs, **Then** the consecutive failure counter MUST be reset to 0. +1. **Given** compact has failed 3 consecutive times, **When** token usage again exceeds the threshold, **Then** compact MUST be skipped and a warning logged. +2. **Given** compact succeeds, **When** the next compact cycle runs, **Then** the consecutive failure counter MUST be reset to 0. --- ### User Story 4 - Post-Compact Context Restoration (Priority: P2) -As an AI agent, after compression replaces conversation history, I want important context re-injected so that I can continue working without losing track of files, directory, plan mode, skills, and background tasks. +As an AI agent, after compact replaces conversation history, I want important context re-injected so that I can continue working without losing track of files, directory, plan mode, skills, and background tasks. -**Why this priority**: Prevents the agent from losing critical environmental context after compression. +**Why this priority**: Prevents the agent from losing critical environmental context after compact. **Independent Test**: Verify that after compression, the summary includes sections for recent file reads, working directory, plan mode status, available skills, and background task status. @@ -88,6 +88,41 @@ As a user working in plan mode during a long session, I want the plan mode instr --- +### User Story 6 - Manual `/compact` Command (Priority: P2) + +As a user, I want to manually trigger conversation compaction with optional custom instructions, so that I can proactively reduce context usage and focus the summary on specific aspects of the conversation. + +**Why this priority**: Gives users control over context management, enabling proactive compaction before auto-compaction triggers and allowing custom focus for the summary. + +**Independent Test**: Type `/compact` or `/compact focus on the bug fix discussion`, verify compaction occurs with the optional instructions applied to the summary. + +**Acceptance Scenarios**: + +1. **Given** a conversation with messages, **When** the user types `/compact`, **Then** the conversation history MUST be compacted immediately regardless of token usage +2. **Given** a conversation with messages, **When** the user types `/compact focus on the API design`, **Then** the custom instructions MUST be passed to the compact API call and influence the summary +3. **Given** an AI response is in progress, **When** the user types `/compact`, **Then** the AI response MUST be aborted before compaction begins +4. **Given** compaction is already in progress, **When** the user types `/compact` again, **Then** the second compaction MUST be skipped (circuit breaker) + +--- + +### User Story 7 - PreCompact and PostCompact Hook Events (Priority: P2) + +As a developer, I want to configure hooks that run before and after conversation compaction, so that I can customize compaction behavior and react to compacted summaries programmatically. + +**Why this priority**: Enables customization of the compaction process and allows downstream systems to react to conversation summaries, but not critical for basic functionality. + +**Independent Test**: Configure PreCompact and PostCompact hooks, trigger compaction via `/compact` command or auto-compaction, verify PreCompact hooks execute before compaction and PostCompact hooks execute after. + +**Acceptance Scenarios**: + +1. **Given** a PreCompact hook is configured, **When** compaction is triggered, **Then** the hook MUST execute before the compact API call, receive `compact_instructions` in JSON input if custom instructions were provided, and its stdout MUST be merged as additional instructions +2. **Given** a PostCompact hook is configured, **When** compaction completes successfully, **Then** the hook MUST execute after the compact API call and receive `compact_summary` in JSON input containing the AI-generated summary +3. **Given** a PreCompact hook returns exit code 2, **When** the hook completes, **Then** the error MUST be shown to the user but compaction MUST continue (non-blocking) +4. **Given** a PostCompact hook returns exit code 2, **When** the hook completes, **Then** the error MUST be shown to the user but execution MUST continue (non-blocking) +5. **Given** both PreCompact and PostCompact hooks are configured, **When** compaction is triggered, **Then** PreCompact MUST run first, then compaction occurs, then SessionStart hooks run, then PostCompact runs last + +--- + ### Edge Cases - **Recursive Compression**: When compressing history that already contains a summary, the entire history (including the old summary) is replaced by a new continuation summary. @@ -109,9 +144,20 @@ As a user working in plan mode during a long session, I want the plan mode instr - **FR-008**: System MUST re-inject post-compact context (recent file reads, working directory, plan mode, skills, background tasks) into the compression summary. When plan mode is active, the system MUST also re-inject the full plan mode `` as a user message after compaction. - **FR-009**: System MUST strip images from messages before the compress API call. - **FR-010**: System MUST use the fast model for compression API calls. -- **FR-011**: System MUST group messages by API round boundaries (not fixed count) when determining which messages to preserve after compression. +- **FR-011**: System MUST group messages by API round boundaries (not fixed count) when determining which messages to preserve after compact. - **FR-012**: System MUST track recent `read` tool results for post-compact context restoration. - **FR-013**: System MUST re-inject plan mode `` instructions after compaction when plan mode is active. This ensures the model does not lose its read-only constraints and workflow guidance after conversation history is replaced with a summary. +- **FR-014**: System MUST support a `/compact` slash command that manually triggers conversation compaction with optional custom instructions +- **FR-015**: System MUST pass custom instructions from `/compact [instructions]` to the compact API call to influence the generated summary +- **FR-016**: System MUST abort any running AI response before processing `/compact` +- **FR-017**: System MUST skip compaction if already in progress (circuit breaker for concurrent compaction) +- **FR-018**: System MUST support PreCompact hooks that execute before conversation compaction +- **FR-019**: System MUST support PostCompact hooks that execute after successful conversation compaction +- **FR-020**: System MUST provide `compact_instructions` field in JSON data for PreCompact events when custom instructions are provided +- **FR-021**: System MUST provide `compact_summary` field in JSON data for PostCompact events containing the AI-generated summary +- **FR-022**: System MUST merge PreCompact hook stdout with user-provided custom instructions as additional compaction instructions +- **FR-023**: System MUST treat PreCompact and PostCompact hook exit code 2 as non-blocking (compaction continues) +- **FR-024**: System MUST NOT require matchers for PreCompact and PostCompact hook configurations ### Key Entities *(include if feature involves data)* diff --git a/specs/014-message-compression/tasks.md b/specs/014-message-compact/tasks.md similarity index 70% rename from specs/014-message-compression/tasks.md rename to specs/014-message-compact/tasks.md index a09f464ae..c545e3740 100644 --- a/specs/014-message-compression/tasks.md +++ b/specs/014-message-compact/tasks.md @@ -1,6 +1,6 @@ -# Tasks: Message Compression +# Tasks: Message Compact -**Input**: Design documents from `/specs/014-message-compression/` +**Input**: Design documents from `/specs/014-message-compact/` **Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md **Tests**: Both unit and integration tests are REQUIRED for all new functionality. Ensure tests are written and failing before implementation. @@ -132,3 +132,36 @@ - [ ] T017 [P] Investigate if image metadata should be preserved in summaries - [ ] T018 [P] Final type-check and linting + +--- + +## Phase 10: User Story 6 - Manual `/compact` Command (Priority: P2) + +**Goal**: Enable manual compaction via `/compact` slash command with optional custom instructions + +**Independent Test**: Type `/compact` or `/compact custom instructions`, verify compaction occurs + +### Implementation for User Story 6 + +- [X] T070 [US6] Extract `compactConversation()` public method in `packages/agent-sdk/src/managers/aiManager.ts` +- [X] T071 [US6] Extract `buildPostCompactContext()` private helper from `handleTokenUsageAndCompaction()` in `packages/agent-sdk/src/managers/aiManager.ts` +- [X] T072 [US6] Refactor `handleTokenUsageAndCompaction()` to delegate to `compactConversation()` in `packages/agent-sdk/src/managers/aiManager.ts` +- [X] T073 [US6] Add `customInstructions` to `CompactMessagesOptions` in `packages/agent-sdk/src/services/aiService.ts` +- [X] T074 [US6] Add `/compact` built-in command in `packages/agent-sdk/src/managers/slashCommandManager.ts` + +--- + +## Phase 11: User Story 7 - PreCompact and PostCompact Hook Events (Priority: P2) + +**Goal**: Enable hooks before and after conversation compaction + +**Independent Test**: Configure PreCompact/PostCompact hooks, trigger compaction, verify execution order + +### Implementation for User Story 7 + +- [X] T080 [US7] Add `PreCompact` and `PostCompact` to `HookEvent` type in `packages/agent-sdk/src/types/hooks.ts` +- [X] T081 [US7] Add `compactInstructions`, `compactSummary` to `ExtendedHookExecutionContext` and `HookJsonInput` in `packages/agent-sdk/src/types/hooks.ts` +- [X] T082 [US7] Update `HookManager` for PreCompact/PostCompact events (configApplies, validateEventConfig, handleBlockingError, getConfigurationStats) in `packages/agent-sdk/src/managers/hookManager.ts` +- [X] T083 [US7] Add `executePreCompactHooks()` and `executePostCompactHooks()` convenience methods in `packages/agent-sdk/src/managers/hookManager.ts` +- [X] T084 [US7] Wire PreCompact and PostCompact hooks into `compactConversation()` in `packages/agent-sdk/src/managers/aiManager.ts` +- [X] T085 [US7] Add tests for `compactConversation` in `packages/agent-sdk/tests/managers/aiManager.compactConversation.test.ts` diff --git a/specs/014-message-compression/data-model.md b/specs/014-message-compression/data-model.md deleted file mode 100644 index 04364da14..000000000 --- a/specs/014-message-compression/data-model.md +++ /dev/null @@ -1,19 +0,0 @@ -# Data Model: Message Compression - -## Entities - -### CompressBlock -Used in **user** messages to store the summary of compressed history (matching Claude Code's auto-compact behavior). - -| Field | Type | Description | -|-------|------|-------------| -| `type` | 'compress' | Identifies the block as a compression summary. | -| `content` | string | The summary text generated by the AI. | - -## State Transitions - -### History Compression -1. **Monitoring**: `AIManager` checks token usage after each response. -2. **Triggered**: Usage exceeds threshold; all current messages are selected for compression. -3. **Summarizing**: AI generates a continuation summary of the conversation. -4. **Compressed**: Entire history is replaced by a single `CompressBlock` and a new session ID is generated. diff --git a/specs/014-message-compression/quickstart.md b/specs/014-message-compression/quickstart.md deleted file mode 100644 index 1766b4c7e..000000000 --- a/specs/014-message-compression/quickstart.md +++ /dev/null @@ -1,26 +0,0 @@ -# Quickstart: Message Compression - -## Overview -This feature manages conversation history through automatic summarization. - -## Development Setup -1. Build the `agent-sdk` to include compression utilities: - ```bash - pnpm -F agent-sdk build - ``` - -## Verification Steps - -### Unit Tests -Run tests for history compression: -```bash -pnpm -F agent-sdk test tests/agent/agent.compression.test.ts -``` - -### Manual Verification - -#### History Compression -1. Set a low `maxInputTokens` in the agent config or mock token usage. -2. Engage in a long conversation. -3. Verify that the entire conversation history is replaced by a single continuation summary block. -4. Verify the agent still remembers the general context of the summarized messages. diff --git a/specs/README.md b/specs/README.md index 8def0aaa5..bbd11e798 100644 --- a/specs/README.md +++ b/specs/README.md @@ -29,10 +29,10 @@ This directory contains feature specifications that serve as the source of truth | Metric | Count | |--------|-------| | Specs | 54 | -| User Stories | 232 | -| Functional Requirements | 862 | -| Test Files | 301 | -| Test Cases | 3,813 | +| User Stories | 236 | +| Functional Requirements | 880 | +| Test Files | 302 | +| Test Cases | 3,822 | ## Specs @@ -42,7 +42,7 @@ This directory contains feature specifications that serve as the source of truth | Bash Tools | Bash, BashOutput, KillBash tools for shell command execution | 3 | 17 | [spec](002-bash-tools/spec.md) · [plan](002-bash-tools/plan.md) | | MCP | Model Context Protocol support for external tools and context sources | 4 | 23 | [spec](003-mcp/spec.md) · [plan](003-mcp/plan.md) | | Session Management | Performance-optimized, project-based session management system | 3 | 17 | [spec](004-session-management/spec.md) · [plan](004-session-management/plan.md) | -| Hooks | Event hooks system for extending Wave behavior | 14 | 55 | [spec](005-hooks/spec.md) · [plan](005-hooks/plan.md) | +| Hooks | Event hooks system for extending Wave behavior | 16 | 62 | [spec](005-hooks/spec.md) · [plan](005-hooks/plan.md) | | Agent Skills | Discoverable skill packages with SKILL.md files for model-invoked capabilities | 8 | 25 | [spec](006-agent-skills/spec.md) · [plan](006-agent-skills/plan.md) | | Agent Config | Constructor-based config instead of env vars, with max output tokens and custom headers | 10 | 41 | [spec](007-agent-config/spec.md) · [plan](007-agent-config/plan.md) | | Slash Commands | Custom slash command system for user-invoked commands | 6 | 22 | [spec](008-slash-commands/spec.md) · [plan](008-slash-commands/plan.md) | @@ -50,7 +50,7 @@ This directory contains feature specifications that serve as the source of truth | Usage Tracking | SDK usage tracking callbacks (`onUsagesChange`) for AI calls and compression | 4 | 15 | [spec](010-usage-tracking-callback/spec.md) · [plan](010-usage-tracking-callback/plan.md) | | Streaming | Real-time content streaming for assistant messages and tool parameters | 5 | 22 | [spec](012-stream-content-updates/spec.md) · [plan](012-stream-content-updates/plan.md) | | AI Error Handling | Handle output token limit exceeded by prompting agent to break work into smaller pieces | 6 | 10 | [spec](013-ai-error-handling/spec.md) · [plan](013-ai-error-handling/plan.md) | -| Message Compression | Conversation history and user input size management | 5 | 13 | [spec](014-message-compression/spec.md) · [plan](014-message-compression/plan.md) | +| Message Compression | Conversation history and user input size management | 7 | 24 | [spec](014-message-compression/spec.md) · [plan](014-message-compression/plan.md) | | Image Pasting | Paste images from clipboard into chat input with placeholder and attachment | 3 | 10 | [spec](015-image-pasting/spec.md) · [plan](015-image-pasting/plan.md) | | File Selector | Quick file/directory selector UI component | 3 | 8 | [spec](016-file-selector/spec.md) · [plan](016-file-selector/plan.md) | | WebFetch Tool | Fetch URL content, convert HTML to markdown, process with AI model, with caching | 5 | 14 | [spec](017-web-fetch-tool/spec.md) · [plan](017-web-fetch-tool/plan.md) |