From 4eab0817191684599404a7d31e34a51452a9be3f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:41:04 -0700 Subject: [PATCH] Plugin: add proactive context usage alerts with one-tap compact Send a warning when context remaining drops below 25% and a critical alert below 10% after each turn. Each alert includes a one-tap "Compact Now" button. Alerts fire once per threshold crossing and reset after compaction. Co-Authored-By: Claude Opus 4.6 --- src/controller.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++ src/format.test.ts | 31 ++++++++++++++++++++++ src/format.ts | 12 +++++++++ src/types.ts | 5 ++++ 4 files changed, 113 insertions(+) diff --git a/src/controller.ts b/src/controller.ts index 083cff2..15a3387 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -45,6 +45,7 @@ import { formatSkills, formatThreadButtonLabel, formatThreadPickerIntro, + formatContextUsageAlert, formatTurnCompletion, } from "./format.js"; import { @@ -58,6 +59,7 @@ import { formatCommandUsage, renderCommandHelpText } from "./help.js"; import type { AccountSummary, CollaborationMode, + ContextAlertLevel, ConversationPreferences, InteractiveMessageRef, PermissionsMode, @@ -89,6 +91,8 @@ import { paginateItems, } from "./thread-picker.js"; import { + CONTEXT_ALERT_CRITICAL_PERCENT, + CONTEXT_ALERT_WARNING_PERCENT, INTERACTIVE_NAMESPACE, PLUGIN_ID, type CallbackAction, @@ -2643,6 +2647,7 @@ export class CodexPluginController { await this.store.upsertBinding({ ...binding, contextUsage: result.usage, + lastContextAlertLevel: null, updatedAt: Date.now(), }); } @@ -3150,6 +3155,12 @@ export class CodexPluginController { ? await this.describeEmptyTurnCompletion() : formatTurnCompletion(result); await this.sendText(params.conversation, completionText); + const updatedBinding = this.store.getBinding(params.conversation); + if (updatedBinding) { + await this.checkContextUsageAlert(params.conversation, updatedBinding).catch((alertError) => { + this.api.logger.debug?.(`codex context usage alert failed: ${String(alertError)}`); + }); + } }) .catch(async (error) => { const message = error instanceof Error ? error.message : String(error); @@ -6590,4 +6601,58 @@ export class CodexPluginController { }); } } + + private async checkContextUsageAlert( + conversation: ConversationTarget, + binding: StoredBinding, + ): Promise { + const usage = binding.contextUsage; + if (!usage || typeof usage.remainingPercent !== "number") { + return; + } + const remaining = usage.remainingPercent; + let level: ContextAlertLevel | null = null; + if (remaining <= CONTEXT_ALERT_CRITICAL_PERCENT) { + level = "critical"; + } else if (remaining <= CONTEXT_ALERT_WARNING_PERCENT) { + level = "warning"; + } + if (!level) { + if (binding.lastContextAlertLevel) { + await this.store.upsertBinding({ + ...binding, + lastContextAlertLevel: null, + updatedAt: Date.now(), + }); + } + return; + } + const previous = binding.lastContextAlertLevel; + if (previous === level) { + return; + } + if (previous === "critical" && level === "warning") { + return; + } + const alertText = formatContextUsageAlert({ level, usage }); + const compactCallback = await this.store.putCallback({ + kind: "run-prompt", + conversation, + prompt: "/cas_compact", + }); + const buttons: PluginInteractiveButtons = [ + [ + { + text: "Compact Now", + callback_data: `${INTERACTIVE_NAMESPACE}:${compactCallback.token}`, + }, + ], + ]; + await this.sendText(conversation, alertText, { buttons }); + await this.store.upsertBinding({ + ...binding, + lastContextAlertLevel: level, + updatedAt: Date.now(), + }); + } } diff --git a/src/format.test.ts b/src/format.test.ts index f8948b0..a5c8238 100644 --- a/src/format.test.ts +++ b/src/format.test.ts @@ -11,6 +11,7 @@ import { formatCodexPlanSteps, formatCodexReviewFindingMessage, formatCodexStatusText, + formatContextUsageAlert, getCodexStatusTimeZoneLabel, formatMcpServers, formatModels, @@ -566,3 +567,33 @@ describe("formatCodexPlanInlineText", () => { expect(formatCodexPlanInlineText(plan)).toContain("# Plan"); }); }); + +describe("formatContextUsageAlert", () => { + it("returns a warning message when level is warning", () => { + const result = formatContextUsageAlert({ + level: "warning", + usage: { totalTokens: 150_000, contextWindow: 200_000, remainingPercent: 25 }, + }); + expect(result).toContain("Context notice:"); + expect(result).toContain("Consider compacting"); + expect(result).toContain("150k / 200k tokens used"); + }); + + it("returns a critical message when level is critical", () => { + const result = formatContextUsageAlert({ + level: "critical", + usage: { totalTokens: 186_000, contextWindow: 200_000, remainingPercent: 7 }, + }); + expect(result).toContain("Context alert:"); + expect(result).toContain("Compact soon"); + expect(result).toContain("186k / 200k tokens used"); + }); + + it("falls back to unknown usage when snapshot is empty", () => { + const result = formatContextUsageAlert({ + level: "warning", + usage: {}, + }); + expect(result).toContain("unknown usage"); + }); +}); diff --git a/src/format.ts b/src/format.ts index fca83ca..5a1436d 100644 --- a/src/format.ts +++ b/src/format.ts @@ -2,6 +2,7 @@ import os from "node:os"; import { formatModelCapabilitySuffix } from "./model-capabilities.js"; import type { AccountSummary, + ContextAlertLevel, ContextUsageSnapshot, ExperimentalFeatureSummary, McpServerSummary, @@ -979,3 +980,14 @@ export function formatCodexPlanAttachmentFallback( } return lines.join("\n").trim(); } + +export function formatContextUsageAlert(params: { + level: ContextAlertLevel; + usage: ContextUsageSnapshot; +}): string { + const usageText = formatCodexContextUsageSnapshot(params.usage) ?? "unknown usage"; + if (params.level === "critical") { + return `Context alert: ${usageText}. Compact soon to avoid degraded output.`; + } + return `Context notice: ${usageText}. Consider compacting to free up space.`; +} diff --git a/src/types.ts b/src/types.ts index 03b8e75..9ef5cbc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ export const CALLBACK_TOKEN_BYTES = 9; export const CALLBACK_TTL_MS = 30 * 60_000; export const PENDING_INPUT_TTL_MS = 7 * 24 * 60 * 60_000; export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +export const CONTEXT_ALERT_WARNING_PERCENT = 25; +export const CONTEXT_ALERT_CRITICAL_PERCENT = 10; export type CodexTransport = "stdio" | "websocket"; export type PermissionsMode = "default" | "full-access"; @@ -266,10 +268,13 @@ export type StoredBinding = { threadTitle?: string; pinnedBindingMessage?: InteractiveMessageRef; contextUsage?: ContextUsageSnapshot; + lastContextAlertLevel?: ContextAlertLevel | null; preferences?: ConversationPreferences; updatedAt: number; }; +export type ContextAlertLevel = "warning" | "critical"; + export type InteractiveMessageRef = | { provider: "telegram";