From f9b7dbf3a9c96dc0f877dacc75b24c5ba37533d4 Mon Sep 17 00:00:00 2001 From: _blitzzz Date: Sun, 8 Feb 2026 14:27:12 +0700 Subject: [PATCH 1/2] feat: dynamic context pressure warning with model-aware thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MODEL_CONTEXT_WINDOWS map with known model context sizes (Claude, GPT, Gemini, DeepSeek) - Compute context pressure per turn: rawWindow Ɨ 70% threshold, status labels (šŸŸ¢šŸŸ”šŸŸ šŸ”“) - Add contextPressure to SessionState (transient, recomputed each turn) - Status bar: replace ā‚Š with | separator, show status emoji + percent (e.g. 🟔 59%) - Reasoning notifications: show 🧠 (xN) instead of 🧠 "—" for thinking blocks - Reminder checkpoint: show model info, remaining tokens, savings from pruning - Replace static maxContextTokens with fallbackContextWindow + warningThresholdPercent - Remove outputBufferPercent — use raw context window directly --- lib/config/defaults.ts | 3 +- lib/config/schema.ts | 12 +- lib/hooks.ts | 40 ++++++ lib/messages/todo-reminder.ts | 65 +++++++-- lib/state/state.ts | 10 ++ lib/state/types.ts | 19 +++ lib/strategies/utils.ts | 155 ++++++++++++++++++++++ lib/ui/minimal-notifications.ts | 24 +++- lib/ui/notification.ts | 4 +- lib/ui/pruning-status.ts | 16 ++- lib/ui/utils.ts | 13 +- tests/e2e/aggressive-pruning.test.ts | 10 ++ tests/e2e/auto-supersede.test.ts | 10 ++ tests/e2e/reminders.test.ts | 13 +- tests/e2e/stuck-tasks.test.ts | 13 +- tests/fixtures/mock-client.ts | 10 ++ tests/integration/automata-hook.test.ts | 1 + tests/integration/unified-context.test.ts | 10 ++ tests/messages/todo-reminder.test.ts | 12 ++ tests/ui/pruning-status.test.ts | 4 +- 20 files changed, 410 insertions(+), 34 deletions(-) diff --git a/lib/config/defaults.ts b/lib/config/defaults.ts index 3e0e3d6..6ae1dbb 100644 --- a/lib/config/defaults.ts +++ b/lib/config/defaults.ts @@ -67,7 +67,8 @@ export const DEFAULT_CONFIG: PluginConfig = { initialTurns: 5, repeatTurns: 4, stuckTaskTurns: 12, - maxContextTokens: 100000, + fallbackContextWindow: 200000, + warningThresholdPercent: 0.7, }, automataMode: { enabled: true, diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 014aa15..35ca206 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -85,11 +85,17 @@ export const TodoReminderSchema = z.object({ .positive() .default(12) .describe("Number of turns a task can be in_progress before suggesting breakdown"), - maxContextTokens: z + fallbackContextWindow: z .number() .positive() - .default(100000) - .describe("Maximum context token threshold for todo reminders"), + .default(200000) + .describe("Fallback context window size when model is unknown (tokens)"), + warningThresholdPercent: z + .number() + .min(0) + .max(1) + .default(0.7) + .describe("Percentage of effective input that triggers context warning (0.7 = 70%)"), }) export const AutomataModeSchema = z.object({ diff --git a/lib/hooks.ts b/lib/hooks.ts index 94ad088..09e2811 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -24,6 +24,11 @@ import { sendUnifiedNotification } from "./ui/notification" import { getCurrentParams } from "./strategies/utils" import { saveSessionState } from "./state/persistence" import { isSyntheticMessage } from "./shared-utils" +import { + calculateTotalContextTokens, + getEffectiveContextThreshold, + getContextStatus, +} from "./strategies/utils" type Strategy = ( state: SessionState, @@ -80,6 +85,41 @@ export function createChatMessageTransformHandler( ): Promise => { await syncSessionState(client, state, config, logger, output.messages) + // Compute context pressure metrics once per turn + safeExecute( + () => { + const { modelId } = getCurrentParams(state, output.messages, logger) + const threshold = getEffectiveContextThreshold(modelId, { + fallbackContextWindow: config.tools.todoReminder.fallbackContextWindow, + warningThresholdPercent: config.tools.todoReminder.warningThresholdPercent, + }) + const currentTokens = calculateTotalContextTokens(state, output.messages) + const status = getContextStatus(currentTokens, threshold.rawWindow) + + state.contextPressure = { + contextTokens: currentTokens, + effectiveLimit: threshold.rawWindow, + contextPercent: status.percent, + statusLabel: status.label, + statusEmoji: status.emoji, + modelMatch: threshold.modelMatch, + totalSaved: state.stats.totalPruneTokens, + remaining: status.remaining, + } + + logger.debug("Context pressure computed", { + modelId: modelId ?? "unknown", + modelMatch: threshold.modelMatch ?? "fallback", + rawWindow: threshold.rawWindow, + currentTokens, + percent: status.percent, + status: status.label, + }) + }, + logger, + "computeContextPressure", + ) + if (state.isSubAgent) { return } diff --git a/lib/messages/todo-reminder.ts b/lib/messages/todo-reminder.ts index fb7a0a7..22fb964 100644 --- a/lib/messages/todo-reminder.ts +++ b/lib/messages/todo-reminder.ts @@ -2,7 +2,6 @@ import type { SessionState, WithParts } from "../state/types" import type { PluginConfig } from "../config" import type { Logger } from "../logger" import { isMessageCompacted } from "../shared-utils" -import { calculateTotalContextTokens } from "../strategies/utils" /** * Format token count for display (e.g., 1234 -> "1.2K", 12345 -> "12.3K") @@ -15,8 +14,7 @@ function formatTokens(tokens: number): string { const REMINDER_TEMPLATE = `::synth:: --- ## šŸ”– Checkpoint -{context_pressure} -I've noticed your todo list hasn't been updated for {turns} turns. Before continuing: +{context_section} ### 1. Reflect — What changed? Any new risks or blockers? ### 2. Update — Call \`todowrite\` to sync progress @@ -26,6 +24,16 @@ Use prunable_hash values from \`\`, \`\`, \` 0 ? cp.effectiveLimit : todoConfig.fallbackContextWindow + + // Build model line (only if we detected a model) + let modelLine = "" + if (cp.modelMatch) { + modelLine = MODEL_LINE_TEMPLATE.replace("{model_name}", cp.modelMatch).replace( + "{raw_window}", + formatTokens(rawWindow), + ) + } + + // Build savings line (only if we've saved tokens) + let savingsLine = "" + if (cp.totalSaved > 0) { + savingsLine = SAVINGS_LINE_TEMPLATE.replace("{saved_tokens}", formatTokens(cp.totalSaved)) + } + + return CONTEXT_SECTION_TEMPLATE.replace("{percent}", String(cp.contextPercent)) + .replace("{status_emoji}", cp.statusEmoji) + .replace("{status_label}", cp.statusLabel) + .replace("{remaining}", formatTokens(cp.remaining)) + .replace("{current_tokens}", formatTokens(cp.contextTokens)) + .replace("{raw_window}", formatTokens(rawWindow)) + .replace("{model_line}", modelLine) + .replace("{savings_line}", savingsLine) + .trim() +} + /** * Remove any todo reminder from messages. * Called when todowrite is detected to clean up the reminder. @@ -144,11 +189,8 @@ export function injectTodoReminder( // Remove any existing reminder messages first (ensure only one exists) removeTodoReminder(state, messages, logger) - // Calculate context pressure - const currentTokens = calculateTotalContextTokens(state, messages) - const maxTokens = config.tools.todoReminder.maxContextTokens ?? 100000 // Default 100K - const pressurePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100)) - const contextPressure = `\n⚔ **Context: ${pressurePercent}%** (${formatTokens(currentTokens)}/${formatTokens(maxTokens)} tokens)\n` + // Build context pressure section from pre-computed state + const contextSection = buildContextSection(state, config) // Detect stuck tasks (in_progress for too long) const stuckTaskTurns = config.tools.todoReminder.stuckTaskTurns ?? 12 @@ -172,9 +214,10 @@ export function injectTodoReminder( } // Create reminder content - const reminderContent = REMINDER_TEMPLATE.replace("{turns}", String(turnsSinceTodo)) - .replace("{context_pressure}", contextPressure) - .replace("{stuck_task_guidance}", stuckTaskSection) + const reminderContent = REMINDER_TEMPLATE.replace("{context_section}", contextSection).replace( + "{stuck_task_guidance}", + stuckTaskSection, + ) // Create a new user message with the reminder const reminderMessage: WithParts = { diff --git a/lib/state/state.ts b/lib/state/state.ts index 9e31134..665d2e9 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -129,6 +129,16 @@ export function createSessionState(): SessionState { }, }, todos: [], + contextPressure: { + contextTokens: 0, + effectiveLimit: 0, + contextPercent: 0, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: null, + totalSaved: 0, + remaining: 0, + }, } } diff --git a/lib/state/types.ts b/lib/state/types.ts index 1211a9e..30b488b 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -83,6 +83,22 @@ export interface TodoItem { inProgressSince?: number // Turn when task became in_progress (for stuck task detection) } +/** + * Transient context pressure metrics. + * Computed once per turn from model info and current context size. + * NOT persisted - recalculated on every turn. + */ +export interface ContextPressure { + contextTokens: number // Current token count in context + effectiveLimit: number // Effective input limit after output buffer + contextPercent: number // Current usage percentage (0-100) + statusLabel: string // Status label (Nominal, Elevated, High, Critical) + statusEmoji: string // Status emoji (🟢, 🟔, 🟠, šŸ”“) + modelMatch: string | null // Detected model name (or null if unknown) + totalSaved: number // Total tokens saved via pruning + remaining: number // Tokens remaining before effective limit +} + /** * Transient runtime cache for O(1) lookups. * NOT persisted - rebuilt on demand from arrays. @@ -166,6 +182,9 @@ export interface SessionState { todos: TodoItem[] // Current todo list state + // Transient context pressure - NOT persisted, recomputed each turn + contextPressure: ContextPressure + // Transient runtime cache - NOT persisted, rebuilt on demand _cache?: RuntimeCache } diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index ada79ba..cd263b1 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -278,3 +278,158 @@ export const calculateTokensSaved = ( return 0 } } + +/** + * Model context window sizes in tokens. + * Used for dynamic threshold calculation based on the active model. + * Values represent raw context window limits (not accounting for output buffer). + * + * Models are matched by substring (case-insensitive) in order defined below. + * First match wins, so specific models should come before generic ones. + */ +export const MODEL_CONTEXT_WINDOWS: Array<{ + pattern: string + windowSize: number + displayName: string +}> = [ + // Anthropic Claude (200K) + { pattern: "claude-opus", windowSize: 200000, displayName: "Claude Opus" }, + { pattern: "claude-sonnet", windowSize: 200000, displayName: "Claude Sonnet" }, + { pattern: "claude-haiku", windowSize: 200000, displayName: "Claude Haiku" }, + { pattern: "claude-3-", windowSize: 200000, displayName: "Claude 3" }, + + // OpenAI (128K) + { pattern: "gpt-4o", windowSize: 128000, displayName: "GPT-4o" }, + { pattern: "gpt-4-turbo", windowSize: 128000, displayName: "GPT-4 Turbo" }, + { pattern: "o1-", windowSize: 200000, displayName: "o1" }, + { pattern: "o3-", windowSize: 200000, displayName: "o3" }, + + // DeepSeek (128K) + { pattern: "deepseek", windowSize: 128000, displayName: "DeepSeek" }, + + // Google Gemini (1M) + { pattern: "gemini-2", windowSize: 1000000, displayName: "Gemini 2" }, + { pattern: "gemini-1.5", windowSize: 1000000, displayName: "Gemini 1.5" }, + { pattern: "gemini-1", windowSize: 1000000, displayName: "Gemini" }, +] + +/** + * Resolve model context window size from model ID. + * Uses substring matching (case-insensitive) against known model patterns. + * + * @param modelId - The model identifier (e.g., "anthropic/claude-opus-4-6") + * @returns Object with windowSize and displayName, or null if unknown + * + * @example + * resolveModelContextWindow("claude-opus-4-6") → { windowSize: 200000, modelMatch: "Claude Opus" } + * resolveModelContextWindow("unknown-model") → null + */ +export function resolveModelContextWindow( + modelId: string, +): { windowSize: number; modelMatch: string } | null { + if (!modelId) return null + + const lowerModelId = modelId.toLowerCase() + + for (const { pattern, windowSize, displayName } of MODEL_CONTEXT_WINDOWS) { + if (lowerModelId.includes(pattern.toLowerCase())) { + return { windowSize, modelMatch: displayName } + } + } + + return null +} + +/** + * Todo reminder configuration for dynamic threshold calculation. + */ +export interface TodoReminderConfig { + fallbackContextWindow: number + warningThresholdPercent: number +} + +/** + * Result of effective context threshold calculation. + */ +export interface ContextThreshold { + rawWindow: number + warningThreshold: number + modelMatch: string | null +} + +/** + * Calculate effective context threshold based on model and config. + * + * Math: + * - rawWindow: Model's native context window (or fallback if unknown) + * - warningThreshold: rawWindow Ɨ warningThresholdPercent [e.g., 200K Ɨ 0.7 = 140K] + * + * @param modelId - The active model identifier (may be undefined) + * @param config - Todo reminder configuration + * @returns ContextThreshold with all calculated values + */ +export function getEffectiveContextThreshold( + modelId: string | undefined, + config: TodoReminderConfig, +): ContextThreshold { + // Resolve model window or use fallback + const resolved = modelId ? resolveModelContextWindow(modelId) : null + const rawWindow = resolved?.windowSize ?? config.fallbackContextWindow + const modelMatch = resolved?.modelMatch ?? null + + // Apply warning threshold (70% default) + const warningThreshold = Math.floor(rawWindow * config.warningThresholdPercent) + + return { + rawWindow, + warningThreshold, + modelMatch, + } +} + +/** + * Context status label with color indicator. + */ +export interface ContextStatus { + label: string + emoji: string + remaining: number + percent: number +} + +/** + * Determine context status label based on current usage vs effective limit. + * + * Status bands (based on currentTokens / effectiveInput): + * - 0-49%: 🟢 Nominal — plenty of room + * - 50-69%: 🟔 Elevated — consider pruning soon + * - 70-89%: 🟠 High — active pruning recommended + * - 90%+: šŸ”“ Critical — prune immediately or risk truncation + * + * @param currentTokens - Current token count in context + * @param effectiveInput - Effective input limit after output buffer + * @returns ContextStatus with label, emoji, remaining tokens, and percentage + */ +export function getContextStatus(currentTokens: number, effectiveInput: number): ContextStatus { + const percent = Math.min(100, Math.round((currentTokens / effectiveInput) * 100)) + const remaining = Math.max(0, effectiveInput - currentTokens) + + let label: string + let emoji: string + + if (percent < 50) { + label = "Nominal" + emoji = "🟢" + } else if (percent < 70) { + label = "Elevated" + emoji = "🟔" + } else if (percent < 90) { + label = "High" + emoji = "🟠" + } else { + label = "Critical" + emoji = "šŸ”“" + } + + return { label, emoji, remaining, percent } +} diff --git a/lib/ui/minimal-notifications.ts b/lib/ui/minimal-notifications.ts index 2bb6c03..5521ce1 100644 --- a/lib/ui/minimal-notifications.ts +++ b/lib/ui/minimal-notifications.ts @@ -227,8 +227,13 @@ export function formatDistillNotification( const icon = targetType ? typeIcons[targetType] + " " : "" if (attemptedTargets && attemptedTargets.length > 0) { + const suffix = attemptedTargets.length > 1 ? ` (+${attemptedTargets.length - 1})` : "" + // For reasoning blocks, show icon + total count, no display name + if (targetType === "reasoning") { + return `${baseNotification}- ${icon}(x${attemptedTargets.length})` + } const firstTarget = attemptedTargets[0]! - // Resolve hash to display name (tool name, "message part", or "thinking block") + // Resolve hash to display name (tool name or "message part") const displayName = resolveTargetDisplayName( firstTarget, state, @@ -236,7 +241,6 @@ export function formatDistillNotification( targetType, ) const truncated = displayName.length > 15 ? displayName.slice(0, 12) + "..." : displayName - const suffix = attemptedTargets.length > 1 ? ` (+${attemptedTargets.length - 1})` : "" return `${baseNotification}- ${icon}${truncated}${suffix}` } @@ -271,8 +275,13 @@ export function formatDiscardNotification( const icon = targetType ? typeIcons[targetType] + " " : "" if (attemptedTargets && attemptedTargets.length > 0) { + const suffix = attemptedTargets.length > 1 ? ` (+${attemptedTargets.length - 1})` : "" + // For reasoning blocks, show icon + total count, no display name + if (targetType === "reasoning") { + return `${baseNotification}- ${icon}(x${attemptedTargets.length})` + } const firstTarget = attemptedTargets[0]! - // Resolve hash to display name (tool name, "message part", or "thinking block") + // Resolve hash to display name (tool name or "message part") const displayName = resolveTargetDisplayName( firstTarget, state, @@ -280,7 +289,6 @@ export function formatDiscardNotification( targetType, ) const truncated = displayName.length > 15 ? displayName.slice(0, 12) + "..." : displayName - const suffix = attemptedTargets.length > 1 ? ` (+${attemptedTargets.length - 1})` : "" return `${baseNotification}- ${icon}${truncated}${suffix}` } @@ -317,11 +325,17 @@ export function formatNoOpNotification( return `${baseNotification}- 0 items` } + const suffix = attemptedTargets.length > 1 ? ` (+${attemptedTargets.length - 1})` : "" + + // For reasoning blocks, show icon + total count, no display name + if (targetType === "reasoning") { + return `${baseNotification}- ${icon}(x${attemptedTargets.length})` + } + // Show first target resolved to display name, truncated to 15 chars const firstTarget = attemptedTargets[0]! const displayName = resolveTargetDisplayName(firstTarget, state, workingDirectory, targetType) const truncated = truncate(displayName, 15) - const suffix = attemptedTargets.length > 1 ? ` (+${attemptedTargets.length - 1})` : "" return `${baseNotification}- ${icon}${truncated}${suffix}` } diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index ddc98da..4d4cee9 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -61,7 +61,7 @@ function buildMinimalMessage( itemizedDistilled?: ItemizedDistilledItem[], workingDirectory?: string, ): string { - const statsMessage = formatStatsHeader(state.stats.strategyStats) + const statsMessage = formatStatsHeader(state) // If we have itemized data, use the new formatter with icons if ( @@ -104,7 +104,7 @@ function buildDetailedMessage(ctx: NotificationContext, showDistillation: boolea } = ctx const simplified = options?.simplified ?? false - let message = formatStatsHeader(state.stats.strategyStats) + let message = formatStatsHeader(state) // Only show pruning details if there are tokens being pruned or distilled const hasPruningActivity = diff --git a/lib/ui/pruning-status.ts b/lib/ui/pruning-status.ts index 41b1a88..35a2194 100644 --- a/lib/ui/pruning-status.ts +++ b/lib/ui/pruning-status.ts @@ -258,16 +258,26 @@ export function formatItemizedDetails( for (const { item, count } of groupedPruned) { const icon = getPruneItemIcon(item.type) const countSuffix = count > 1 ? ` (x${count})` : "" - parts.push(`${icon} ${item.name}${countSuffix}`) + // For reasoning blocks, show only icon + count, no name + if (item.type === "reasoning") { + parts.push(`${icon} (x${count})`) + } else { + parts.push(`${icon} ${item.name}${countSuffix}`) + } } // Add grouped distilled items with icons and quoted summaries const groupedDistilled = groupItems(distilledItems || [], (i) => `${i.type}:${i.summary}`) for (const { item, count } of groupedDistilled) { const icon = getPruneItemIcon(item.type) - const summary = truncateWithQuotes(item.summary, maxContentLength) const countSuffix = count > 1 ? ` (x${count})` : "" - parts.push(`${icon} ${summary}${countSuffix}`) + // For reasoning blocks, show only icon + count, no summary + if (item.type === "reasoning") { + parts.push(`${icon} (x${count})`) + } else { + const summary = truncateWithQuotes(item.summary, maxContentLength) + parts.push(`${icon} ${summary}${countSuffix}`) + } } if (parts.length === 0) { diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index 1217167..f2ccff3 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -26,12 +26,12 @@ export function formatDistilled(distillation?: string[]): string { return "" } -export function formatStatsHeader(strategyStats: SessionState["stats"]["strategyStats"]): string { +export function formatStatsHeader(state: SessionState): string { // Build the categorized status format: - // 怌 šŸ’¬ 2(1.2K) ā–¼ ā‚Š 🧠 1(3.5K) ā–¼ ā‚Š āš™ļø 5(8.1K) ā–¼ ā‚Š ✨ 3(500) 怍 + // 怌 šŸ’¬ 2(1.2K) ā–¼ | 🧠 1(3.5K) ā–¼ | āš™ļø 5(8.1K) ā–¼ | ✨ 3(500) | 🟔 59% 怍 const parts: string[] = [] - const { manualDiscard, autoSupersede, distillation } = strategyStats + const { manualDiscard, autoSupersede, distillation } = state.stats.strategyStats // šŸ’¬ Message discard (with ā–¼) if (manualDiscard.message.count > 0) { @@ -72,12 +72,15 @@ export function formatStatsHeader(strategyStats: SessionState["stats"]["strategy ) } + // Status emoji + context pressure percentage (always shown) + parts.push(`${state.contextPressure.statusEmoji} ${state.contextPressure.contextPercent}%`) + if (parts.length === 0) { return "怌 acp 怍" } - // Join with ā‚Š separator - return `怌 ${parts.join(" ā‚Š ")} 怍` + // Join with | separator + return `怌 ${parts.join(" | ")} 怍` } export function formatPrunedItemsList( diff --git a/tests/e2e/aggressive-pruning.test.ts b/tests/e2e/aggressive-pruning.test.ts index 1673e9e..71155e6 100644 --- a/tests/e2e/aggressive-pruning.test.ts +++ b/tests/e2e/aggressive-pruning.test.ts @@ -113,6 +113,16 @@ const createMockState = (): SessionState => }, }, todos: [], + contextPressure: { + contextTokens: 50000, + effectiveLimit: 160000, + contextPercent: 31, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 110000, + }, stats: { strategyStats: { autoSupersede: { diff --git a/tests/e2e/auto-supersede.test.ts b/tests/e2e/auto-supersede.test.ts index c79f0dd..e359d2b 100644 --- a/tests/e2e/auto-supersede.test.ts +++ b/tests/e2e/auto-supersede.test.ts @@ -110,6 +110,16 @@ const createMockState = (): SessionState => }, }, todos: [], + contextPressure: { + contextTokens: 50000, + effectiveLimit: 160000, + contextPercent: 31, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 110000, + }, stats: { strategyStats: { autoSupersede: { diff --git a/tests/e2e/reminders.test.ts b/tests/e2e/reminders.test.ts index a3da7ef..e0dc13a 100644 --- a/tests/e2e/reminders.test.ts +++ b/tests/e2e/reminders.test.ts @@ -29,7 +29,8 @@ const createMockConfig = (overrides: Partial = {}): PluginConfig => initialTurns: 3, repeatTurns: 5, stuckTaskTurns: 12, - maxContextTokens: 100000, + fallbackContextWindow: 200000, + warningThresholdPercent: 0.7, }, automataMode: { enabled: false, @@ -102,6 +103,16 @@ const createMockState = (): SessionState => }, }, todos: [], + contextPressure: { + contextTokens: 50000, + effectiveLimit: 160000, + contextPercent: 31, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 110000, + }, stats: { strategyStats: { autoSupersede: { diff --git a/tests/e2e/stuck-tasks.test.ts b/tests/e2e/stuck-tasks.test.ts index 50458ae..a758d10 100644 --- a/tests/e2e/stuck-tasks.test.ts +++ b/tests/e2e/stuck-tasks.test.ts @@ -41,7 +41,8 @@ const createMockConfig = (overrides: Partial = {}): PluginConfig => initialTurns: 3, repeatTurns: 5, stuckTaskTurns: 12, - maxContextTokens: 100000, + fallbackContextWindow: 200000, + warningThresholdPercent: 0.7, }, automataMode: { enabled: false, @@ -114,6 +115,16 @@ const createMockState = (): SessionState => }, }, todos: [], + contextPressure: { + contextTokens: 50000, + effectiveLimit: 160000, + contextPercent: 31, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 110000, + }, stats: { strategyStats: { autoSupersede: { diff --git a/tests/fixtures/mock-client.ts b/tests/fixtures/mock-client.ts index 506dad1..6177abb 100644 --- a/tests/fixtures/mock-client.ts +++ b/tests/fixtures/mock-client.ts @@ -183,6 +183,16 @@ export function createMockState(overrides: Partial = {}): SessionS }, }, todos: [], + contextPressure: { + contextTokens: 50000, + effectiveLimit: 160000, + contextPercent: 31, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 110000, + }, ...overrides, } } diff --git a/tests/integration/automata-hook.test.ts b/tests/integration/automata-hook.test.ts index 1f2e46d..20f4474 100644 --- a/tests/integration/automata-hook.test.ts +++ b/tests/integration/automata-hook.test.ts @@ -14,6 +14,7 @@ describe("Integration: Automata Hook", () => { logger = { info: vi.fn(), debug: vi.fn(), + warn: vi.fn(), error: vi.fn(), saveContext: vi.fn().mockResolvedValue(undefined), } diff --git a/tests/integration/unified-context.test.ts b/tests/integration/unified-context.test.ts index a9e1199..7fe1769 100644 --- a/tests/integration/unified-context.test.ts +++ b/tests/integration/unified-context.test.ts @@ -94,6 +94,16 @@ describe("Unified Context Tool Integration", () => { distillation: { count: 0, tokens: 0 }, }, }, + contextPressure: { + contextTokens: 50000, + effectiveLimit: 160000, + contextPercent: 31, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 110000, + }, } as any mockClient = { diff --git a/tests/messages/todo-reminder.test.ts b/tests/messages/todo-reminder.test.ts index 117ec84..2e58cf7 100644 --- a/tests/messages/todo-reminder.test.ts +++ b/tests/messages/todo-reminder.test.ts @@ -28,6 +28,8 @@ const createMockConfig = (): PluginConfig => initialTurns: 8, repeatTurns: 4, stuckTaskTurns: 12, + fallbackContextWindow: 200000, + warningThresholdPercent: 0.7, }, }, }) as unknown as PluginConfig @@ -66,6 +68,16 @@ const createMockState = (overrides: Partial = {}): SessionState => }, }, }, + contextPressure: { + contextTokens: 50000, + effectiveLimit: 160000, + contextPercent: 31, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 110000, + }, ...overrides, }) as unknown as SessionState diff --git a/tests/ui/pruning-status.test.ts b/tests/ui/pruning-status.test.ts index 71917b8..a8feee1 100644 --- a/tests/ui/pruning-status.test.ts +++ b/tests/ui/pruning-status.test.ts @@ -277,7 +277,7 @@ describe("formatItemizedDetails", () => { { type: "reasoning" as const, name: "think1" }, ] const result = formatItemizedDetails(pruned, []) - expect(result).toBe("āš™ļø bash (x2) ā‚Š šŸ’¬ msg1 ā‚Š 🧠 think1") + expect(result).toBe("āš™ļø bash (x2) ā‚Š šŸ’¬ msg1 ā‚Š 🧠 (x1)") }) it("should group distilled items with same summary", () => { @@ -297,7 +297,7 @@ describe("formatItemizedDetails", () => { ] const distilled = [{ type: "reasoning" as const, summary: "Analysis complete" }] const result = formatItemizedDetails(pruned, distilled) - expect(result).toBe('āš™ļø read (x2) ā‚Š 🧠 "Analysis comple..."') + expect(result).toBe("āš™ļø read (x2) ā‚Š 🧠 (x1)") }) it("should return empty string for empty arrays", () => { From c21af9d94b2bd379d0bfafdb6588f34072fbd1c8 Mon Sep 17 00:00:00 2001 From: _blitzzz Date: Sun, 8 Feb 2026 15:18:33 +0700 Subject: [PATCH 2/2] fix: status bar accumulation and percentage display - Add shared sumAutoSupersede/sumToolPruneStats utilities (lib/state/stats-utils.ts) - Include all 8 autoSupersede categories + purgeErrors in status bar totals - Fix sendAttemptedNotification bypassing formatStatsHeader on no-op paths - Replace duplicated summation in /acp stats with shared utility - Data-driven breakdown loop for all auto-supersede sub-categories Fixes: status not accumulating (5 categories missing), percentage intermittently absent (no-op notification path skipped stats header) --- lib/commands/stats.ts | 55 +++++++++++++++++----------------------- lib/state/index.ts | 1 + lib/state/stats-utils.ts | 52 +++++++++++++++++++++++++++++++++++++ lib/ui/notification.ts | 12 ++++++++- lib/ui/utils.ts | 16 +++--------- 5 files changed, 90 insertions(+), 46 deletions(-) create mode 100644 lib/state/stats-utils.ts diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index a33526a..1b84334 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -9,6 +9,7 @@ import { sendIgnoredMessage } from "../ui/notification" import { formatTokenCount } from "../ui/utils" import { loadAllSessionStats, type AggregatedStats } from "../state/persistence" import { getCurrentParams } from "../strategies/utils" +import { sumAutoSupersede } from "../state/stats-utils" import packageJson from "../../package.json" with { type: "json" } export interface StatsCommandContext { @@ -42,20 +43,9 @@ function formatStatsMessage( lines.push("Strategy Effectiveness:") lines.push("─".repeat(60)) - // Calculate auto-supersede totals + // Calculate auto-supersede totals (dynamically sums all categories) const autoSupersede = strategyStats.autoSupersede - const autoSupersedeTotal = { - count: - autoSupersede.hash.count + - autoSupersede.file.count + - autoSupersede.todo.count + - autoSupersede.context.count, - tokens: - autoSupersede.hash.tokens + - autoSupersede.file.tokens + - autoSupersede.todo.tokens + - autoSupersede.context.tokens, - } + const autoSupersedeTotal = sumAutoSupersede(autoSupersede) // Calculate manual discard totals (new nested structure) const manualDiscard = strategyStats.manualDiscard @@ -87,25 +77,26 @@ function formatStatsMessage( // Show sub-breakdown for Auto-Supersede if (strat.breakdown === "autoSupersede") { - if (autoSupersede.hash.count > 0) { - lines.push( - ` šŸ”„ hash ${autoSupersede.hash.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.hash.tokens)}`, - ) - } - if (autoSupersede.file.count > 0) { - lines.push( - ` šŸ“ file ${autoSupersede.file.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.file.tokens)}`, - ) - } - if (autoSupersede.todo.count > 0) { - lines.push( - ` āœ… todo ${autoSupersede.todo.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.todo.tokens)}`, - ) - } - if (autoSupersede.context.count > 0) { - lines.push( - ` šŸ”§ context ${autoSupersede.context.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.context.tokens)}`, - ) + const autoEntries: Array<{ + icon: string + label: string + data: { count: number; tokens: number } + }> = [ + { icon: "šŸ”„", label: "hash", data: autoSupersede.hash }, + { icon: "šŸ“", label: "file", data: autoSupersede.file }, + { icon: "āœ…", label: "todo", data: autoSupersede.todo }, + { icon: "šŸ”§", label: "context", data: autoSupersede.context }, + { icon: "🌐", label: "url", data: autoSupersede.url }, + { icon: "šŸ”", label: "stateQuery", data: autoSupersede.stateQuery }, + { icon: "šŸ“ø", label: "snapshot", data: autoSupersede.snapshot }, + { icon: "šŸ”", label: "retry", data: autoSupersede.retry }, + ] + for (const entry of autoEntries) { + if (entry.data.count > 0) { + lines.push( + ` ${entry.icon} ${entry.label.padEnd(14)}${entry.data.count.toString().padStart(3)} prunes, ~${formatTokenCount(entry.data.tokens)}`, + ) + } } } diff --git a/lib/state/index.ts b/lib/state/index.ts index 37681fe..4c6075a 100644 --- a/lib/state/index.ts +++ b/lib/state/index.ts @@ -32,3 +32,4 @@ export * from "./state" export * from "./tool-cache" export * from "./types" export * from "./persistence" +export * from "./stats-utils" diff --git a/lib/state/stats-utils.ts b/lib/state/stats-utils.ts new file mode 100644 index 0000000..9aa604a --- /dev/null +++ b/lib/state/stats-utils.ts @@ -0,0 +1,52 @@ +/** + * Shared utility for summing prune statistics across all strategy categories. + * Single source of truth — used by both formatStatsHeader() and /acp stats command. + */ + +import type { SessionStats } from "./types" + +/** + * Sum all auto-supersede categories dynamically. + * Uses Object.values() so new categories added to SessionStats.autoSupersede + * are automatically included without code changes. + * + * Guards against non-counter properties by checking for numeric .count/.tokens fields. + */ +export function sumAutoSupersede(autoSupersede: SessionStats["strategyStats"]["autoSupersede"]): { + count: number + tokens: number +} { + let count = 0 + let tokens = 0 + for (const entry of Object.values(autoSupersede)) { + if ( + entry && + typeof entry === "object" && + typeof entry.count === "number" && + typeof entry.tokens === "number" + ) { + count += entry.count + tokens += entry.tokens + } + } + return { count, tokens } +} + +/** + * Sum all tool-related prune stats: manual tool discards + all auto-supersede + purge errors. + * This is the "āš™ļø" line in the status bar. + */ +export function sumToolPruneStats(strategyStats: SessionStats["strategyStats"]): { + count: number + tokens: number +} { + const auto = sumAutoSupersede(strategyStats.autoSupersede) + return { + count: + strategyStats.manualDiscard.tool.count + auto.count + strategyStats.purgeErrors.count, + tokens: + strategyStats.manualDiscard.tool.tokens + + auto.tokens + + strategyStats.purgeErrors.tokens, + } +} diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 4d4cee9..029f3b2 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -210,13 +210,23 @@ export async function sendAttemptedNotification( return false } - const message = formatNoOpNotification( + const noOpMessage = formatNoOpNotification( type, attemptedTargets, targetType, state, workingDirectory, ) + // Prepend stats header (with percentage) when state is available; + // extract the detail part after the no-op box to avoid double-boxing + let message: string + if (state) { + const statsHeader = formatStatsHeader(state) + const details = noOpMessage.replace(/^怌 .*? 怍- /, "").trim() + message = `${statsHeader}- ${details}` + } else { + message = noOpMessage + } await sendIgnoredMessage(client, sessionId, message, params, logger) return true } diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index f2ccff3..e3af23d 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -2,6 +2,7 @@ import { ToolParameterEntry, SessionState } from "../state" import { extractParameterKey } from "../messages/utils" import { countTokens } from "../strategies/utils" import { formatTokenCount, truncate, shortenPath } from "../utils/string" +import { sumToolPruneStats } from "../state/stats-utils" // Re-export for backwards compatibility export { formatTokenCount, truncate, shortenPath } @@ -47,19 +48,8 @@ export function formatStatsHeader(state: SessionState): string { ) } - // šŸ”§ Tool discard = manual tool + all auto-supersede (with ā–¼) - const toolCount = - manualDiscard.tool.count + - autoSupersede.hash.count + - autoSupersede.file.count + - autoSupersede.todo.count + - autoSupersede.context.count - const toolTokens = - manualDiscard.tool.tokens + - autoSupersede.hash.tokens + - autoSupersede.file.tokens + - autoSupersede.todo.tokens + - autoSupersede.context.tokens + // šŸ”§ Tool discard = manual tool + all auto-supersede + purge errors (with ā–¼) + const { count: toolCount, tokens: toolTokens } = sumToolPruneStats(state.stats.strategyStats) if (toolCount > 0) { parts.push(`${PRUNE_CATEGORY_ICONS.tool} ${toolCount}(${formatTokenCount(toolTokens)}) ā–¼`)