From f5871c443b77c16fd3ce18436dbc8f4ca2413f70 Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 15:05:04 -0600 Subject: [PATCH 01/17] Allow config to override logging and silence warn toasts --- index.ts | 4 +-- lib/config.ts | 14 ++++++-- lib/logger.ts | 63 +++++++++++++++++++++++++++++------ lib/types.ts | 20 +++++++++++ spec/environment-variables.md | 43 ++++++++++++++++++++++++ test/logger.test.ts | 39 ++++++++++++++++++++-- test/plugin-config.test.ts | 21 ++++++++++++ 7 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 spec/environment-variables.md diff --git a/index.ts b/index.ts index 99759d3..b0630be 100644 --- a/index.ts +++ b/index.ts @@ -64,7 +64,8 @@ import type { UserConfig } from "./lib/types.js"; * ``` */ export const OpenAIAuthPlugin: Plugin = async ({ client, directory }: PluginInput) => { - configureLogger({ client, directory }); + const pluginConfig = loadPluginConfig(); + configureLogger({ client, directory, pluginConfig }); setTimeout(() => { logWarn( "The OpenAI Codex plugin is intended for personal use with your own ChatGPT Plus/Pro subscription. Ensure your usage complies with OpenAI's Terms of Service.", @@ -98,7 +99,6 @@ export const OpenAIAuthPlugin: Plugin = async ({ client, directory }: PluginInpu }; // Load plugin configuration and determine CODEX_MODE - const pluginConfig = loadPluginConfig(); const codexMode = getCodexMode(pluginConfig); const promptCachingEnabled = pluginConfig.enablePromptCaching ?? true; if (!promptCachingEnabled) { diff --git a/lib/config.ts b/lib/config.ts index e4db55a..3616dd2 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -14,6 +14,9 @@ const DEFAULT_CONFIG: PluginConfig = { enablePromptCaching: true, enableCodexCompaction: true, autoCompactMinMessages: 8, + logging: { + showWarningToasts: false, + }, }; let cachedPluginConfig: PluginConfig | undefined; @@ -39,16 +42,21 @@ export function loadPluginConfig(options: { forceReload?: boolean } = {}): Plugi const fileContent = safeReadFile(CONFIG_PATH); if (!fileContent) { logWarn("Plugin config file not found, using defaults", { path: CONFIG_PATH }); - cachedPluginConfig = DEFAULT_CONFIG; + cachedPluginConfig = { ...DEFAULT_CONFIG }; return cachedPluginConfig; } const userConfig = JSON.parse(fileContent) as Partial; + const userLogging = userConfig.logging ?? {}; - // Merge with defaults + // Merge with defaults (shallow merge + nested logging merge) cachedPluginConfig = { ...DEFAULT_CONFIG, ...userConfig, + logging: { + ...DEFAULT_CONFIG.logging, + ...userLogging, + }, }; return cachedPluginConfig; } catch (error) { @@ -56,7 +64,7 @@ export function loadPluginConfig(options: { forceReload?: boolean } = {}): Plugi path: CONFIG_PATH, error: (error as Error).message, }); - cachedPluginConfig = DEFAULT_CONFIG; + cachedPluginConfig = { ...DEFAULT_CONFIG }; return cachedPluginConfig; } } diff --git a/lib/logger.ts b/lib/logger.ts index 23d525a..ea7efa2 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,26 +1,38 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; import { appendFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import type { LoggingConfig, PluginConfig } from "./types.js"; import { PLUGIN_NAME } from "./constants.js"; import { ensureDirectory, getOpenCodePath } from "./utils/file-system-utils.js"; -export const LOGGING_ENABLED = process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1"; -const DEBUG_FLAG_ENABLED = process.env.DEBUG_CODEX_PLUGIN === "1"; -const DEBUG_ENABLED = DEBUG_FLAG_ENABLED || LOGGING_ENABLED; const IS_TEST_ENV = process.env.NODE_ENV === "test"; -const CONSOLE_LOGGING_ENABLED = DEBUG_FLAG_ENABLED && !IS_TEST_ENV; const LOG_DIR = getOpenCodePath("logs", "codex-plugin"); const ROLLING_LOG_FILE = join(LOG_DIR, "codex-plugin.log"); -const LOG_ROTATION_MAX_BYTES = Math.max(1, getEnvNumber("CODEX_LOG_MAX_BYTES", 5 * 1024 * 1024)); -const LOG_ROTATION_MAX_FILES = Math.max(1, getEnvNumber("CODEX_LOG_MAX_FILES", 5)); -const LOG_QUEUE_MAX_LENGTH = Math.max(1, getEnvNumber("CODEX_LOG_QUEUE_MAX", 1000)); +const envLoggingDefaults = { + loggingEnabled: process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1", + debugFlagEnabled: process.env.DEBUG_CODEX_PLUGIN === "1", + showWarningToasts: process.env.CODEX_SHOW_WARNING_TOASTS === "1", + logRotationMaxBytes: getEnvNumber("CODEX_LOG_MAX_BYTES", 5 * 1024 * 1024), + logRotationMaxFiles: getEnvNumber("CODEX_LOG_MAX_FILES", 5), + logQueueMaxLength: getEnvNumber("CODEX_LOG_QUEUE_MAX", 1000), +}; + +export let LOGGING_ENABLED = envLoggingDefaults.loggingEnabled; +let DEBUG_FLAG_ENABLED = envLoggingDefaults.debugFlagEnabled; +let WARN_TOASTS_ENABLED = envLoggingDefaults.showWarningToasts ?? false; +let LOG_ROTATION_MAX_BYTES = Math.max(1, envLoggingDefaults.logRotationMaxBytes); +let LOG_ROTATION_MAX_FILES = Math.max(1, envLoggingDefaults.logRotationMaxFiles); +let LOG_QUEUE_MAX_LENGTH = Math.max(1, envLoggingDefaults.logQueueMaxLength); +let DEBUG_ENABLED = DEBUG_FLAG_ENABLED || LOGGING_ENABLED; +let CONSOLE_LOGGING_ENABLED = DEBUG_FLAG_ENABLED && !IS_TEST_ENV; type LogLevel = "debug" | "info" | "warn" | "error"; type LoggerOptions = { client?: OpencodeClient; directory?: string; + pluginConfig?: PluginConfig; }; type OpencodeClientWithTui = OpencodeClient & { @@ -46,6 +58,35 @@ type RollingLogEntry = { extra?: Record; }; +function refreshLoggingState(): void { + DEBUG_ENABLED = DEBUG_FLAG_ENABLED || LOGGING_ENABLED; + CONSOLE_LOGGING_ENABLED = DEBUG_FLAG_ENABLED && !IS_TEST_ENV; +} + +function ensurePositiveNumber(value: number | undefined, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + return fallback; +} + +function applyLoggingOverrides(logging?: LoggingConfig): void { + if (!logging) { + refreshLoggingState(); + return; + } + + LOGGING_ENABLED = logging.enableRequestLogging ?? LOGGING_ENABLED; + DEBUG_FLAG_ENABLED = logging.debug ?? DEBUG_FLAG_ENABLED; + WARN_TOASTS_ENABLED = logging.showWarningToasts ?? WARN_TOASTS_ENABLED; + LOG_ROTATION_MAX_BYTES = ensurePositiveNumber(logging.logMaxBytes, LOG_ROTATION_MAX_BYTES); + LOG_ROTATION_MAX_FILES = ensurePositiveNumber(logging.logMaxFiles, LOG_ROTATION_MAX_FILES); + LOG_QUEUE_MAX_LENGTH = ensurePositiveNumber(logging.logQueueMax, LOG_QUEUE_MAX_LENGTH); + refreshLoggingState(); +} + +refreshLoggingState(); + let requestCounter = 0; let loggerClient: OpencodeClient | undefined; let projectDirectory: string | undefined; @@ -66,6 +107,7 @@ export function configureLogger(options: LoggerOptions = {}): void { if (options.directory) { projectDirectory = options.directory; } + applyLoggingOverrides(options.pluginConfig?.logging); if (announcedState || !(LOGGING_ENABLED || DEBUG_ENABLED)) { return; } @@ -127,6 +169,7 @@ export async function flushRollingLogsForTest(): Promise { function emit(level: LogLevel, message: string, extra?: Record): void { const sanitizedExtra = sanitizeExtra(extra); const supportsToast = loggerClient ? hasTuiShowToast(loggerClient) : false; + const warnToastEnabled = supportsToast && WARN_TOASTS_ENABLED; const entry: RollingLogEntry = { timestamp: new Date().toISOString(), service: PLUGIN_NAME, @@ -139,7 +182,7 @@ function emit(level: LogLevel, message: string, extra?: Record) appendRollingLog(entry); } - const shouldForwardToAppLog = level !== "warn" || !supportsToast; + const shouldForwardToAppLog = level !== "warn" || !warnToastEnabled; if (shouldForwardToAppLog && loggerClient?.app?.log) { void loggerClient.app @@ -154,11 +197,11 @@ function emit(level: LogLevel, message: string, extra?: Record) ); } - if (level === "error" || (level === "warn" && supportsToast)) { + if (level === "error" || (level === "warn" && warnToastEnabled)) { notifyToast(level, message, sanitizedExtra); } - const shouldLogToConsole = level !== "warn" || !supportsToast; + const shouldLogToConsole = level !== "warn" || !warnToastEnabled; if (shouldLogToConsole) { logToConsole(level, message, sanitizedExtra); } diff --git a/lib/types.ts b/lib/types.ts index 0c4439d..c939039 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -32,6 +32,26 @@ export interface PluginConfig { * Minimum number of conversation messages before auto-compacting */ autoCompactMinMessages?: number; + + /** + * Logging configuration that can override environment variables + */ + logging?: LoggingConfig; +} + +export interface LoggingConfig { + /** When true, persist detailed request logs regardless of env var */ + enableRequestLogging?: boolean; + /** When true, enable debug logging regardless of env var */ + debug?: boolean; + /** Whether warning-level toasts should be shown (default: false) */ + showWarningToasts?: boolean; + /** Override max bytes before rolling log rotation */ + logMaxBytes?: number; + /** Override number of rotated log files to keep */ + logMaxFiles?: number; + /** Override rolling log queue length */ + logQueueMax?: number; } /** diff --git a/spec/environment-variables.md b/spec/environment-variables.md new file mode 100644 index 0000000..d662158 --- /dev/null +++ b/spec/environment-variables.md @@ -0,0 +1,43 @@ +# Environment Variables Audit + +## Context + +- User requested a list/summary of all environment variables used by this repository. + +## Sources (code refs) + +- lib/logger.ts:12-27,428-435 (logging flags, rotation tuning via env, generic accessor) +- lib/config.ts:72-76 (CODEX_MODE override) +- scripts/sync-github-secrets.mjs:5-10,63-65,106-114 (default secret names, repo detection, env lookup) +- scripts/review-response-context.mjs:7-10,104-110 (GitHub Actions paths/outputs) +- scripts/detect-release-type.mjs:18-21,30-31 (release base ref/head sha overrides) + +## Environment variables and purposes + +- `ENABLE_PLUGIN_REQUEST_LOGGING` (`lib/logger.ts:7`): when "1", persist detailed request logs. +- `DEBUG_CODEX_PLUGIN` (`lib/logger.ts:8`): when "1", enable debug logging/console output (unless NODE_ENV=test). +- `NODE_ENV` (`lib/logger.ts:10`): if "test", suppress console logging during tests. +- `CODEX_LOG_MAX_BYTES` (`lib/logger.ts:16`): max rolling log file size before rotation (default 5MB). +- `CODEX_LOG_MAX_FILES` (`lib/logger.ts:17`): how many rotated log files to keep (default 5). +- `CODEX_LOG_QUEUE_MAX` (`lib/logger.ts:18`): max queued log entries before overflow warning (default 1000). +- `CODEX_SHOW_WARNING_TOASTS` (`lib/logger.ts:15`): when "1", allow warning-level toasts (config default keeps them off). +- `CODEX_MODE` (`lib/config.ts:72-76`): if set, overrides config; "1" enables Codex bridge/tool mapping, otherwise disables. +- `GITHUB_REPOSITORY` (`scripts/sync-github-secrets.mjs:63-65`): optional repo inference fallback for syncing secrets. +- `NPM_TOKEN`, `OPENCODE_API_KEY`, `OPENCODE_API_URL`, `RELEASE_BASE_REF` (`scripts/sync-github-secrets.mjs:5-10`): default env names expected when syncing secrets (first two required, latter two optional unless explicitly requested). +- `GITHUB_EVENT_PATH` (`scripts/review-response-context.mjs:7-10`): required path to event payload in review-comment workflow. +- `GITHUB_OUTPUT` (`scripts/review-response-context.mjs:104-110`): optional path to append action outputs. +- `RELEASE_BASE_REF` (`scripts/detect-release-type.mjs:18-21`): optional override for release comparison base. +- `GITHUB_SHA` (`scripts/detect-release-type.mjs:30-31`): optional head sha override (falls back to git rev-parse HEAD). + +## Existing issues/PRs + +- None identified during this audit. + +## Definition of done + +- Enumerate all environment variables referenced in code/scripts with locations and purposes; provide user-facing summary. + +## Notes + +- Secret sync script can read any env name specified via CLI args in addition to defaults; above list reflects defaults plus repo inference variables. +- Logging-related env vars can be overridden in ~/.opencode/openhax-codex-config.json via the `logging` block. diff --git a/test/logger.test.ts b/test/logger.test.ts index ff0c863..d7d7751 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -107,6 +107,20 @@ describe("logger", () => { expect(fsMocks.appendFile).not.toHaveBeenCalled(); }); + it("config overrides env-enabled request logging when disabled in file", async () => { + process.env.ENABLE_PLUGIN_REQUEST_LOGGING = "1"; + fsMocks.existsSync.mockReturnValue(true); + const { configureLogger, logRequest, flushRollingLogsForTest } = await import("../lib/logger.js"); + + configureLogger({ pluginConfig: { logging: { enableRequestLogging: false } } }); + + logRequest("stage-one", { foo: "bar" }); + await flushRollingLogsForTest(); + + expect(fsMocks.writeFile).not.toHaveBeenCalled(); + expect(fsMocks.appendFile).not.toHaveBeenCalled(); + }); + it("logDebug appends to rolling log only when enabled", async () => { process.env.ENABLE_PLUGIN_REQUEST_LOGGING = "1"; fsMocks.existsSync.mockReturnValue(true); @@ -129,7 +143,7 @@ describe("logger", () => { expect(warnSpy).toHaveBeenCalledWith("[openhax/codex] warning"); }); - it("logWarn sends toast and avoids console/app log when tui available", async () => { + it("logWarn does not send warning toasts by default even when tui is available", async () => { fsMocks.existsSync.mockReturnValue(true); const showToast = vi.fn(); const appLog = vi.fn().mockResolvedValue(undefined); @@ -145,6 +159,27 @@ describe("logger", () => { logWarn("toast-warning"); await flushRollingLogsForTest(); + expect(showToast).not.toHaveBeenCalled(); + expect(appLog).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith("[openhax/codex] toast-warning"); + }); + + it("logWarn sends warning toasts only when enabled via config", async () => { + fsMocks.existsSync.mockReturnValue(true); + const showToast = vi.fn(); + const appLog = vi.fn().mockResolvedValue(undefined); + const { configureLogger, logWarn, flushRollingLogsForTest } = await import("../lib/logger.js"); + + const client = { + app: { log: appLog }, + tui: { showToast }, + } as unknown as OpencodeClient; + + configureLogger({ client, pluginConfig: { logging: { showWarningToasts: true } } }); + + logWarn("toast-warning"); + await flushRollingLogsForTest(); + expect(showToast).toHaveBeenCalledWith({ body: { title: "openhax/codex warning", @@ -167,7 +202,7 @@ describe("logger", () => { tui: { showToast }, } as unknown as OpencodeClient; - configureLogger({ client }); + configureLogger({ client, pluginConfig: { logging: { showWarningToasts: true } } }); logWarn( "prefix mismatch detected while warming the session cache; reconnecting with fallback account boundaries", diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 2ffa5e1..6a30af5 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -54,7 +54,9 @@ describe("Plugin Configuration", () => { enablePromptCaching: true, enableCodexCompaction: true, autoCompactMinMessages: 8, + logging: { showWarningToasts: false }, }); + expect(mockExistsSync).toHaveBeenCalledWith( path.join(os.homedir(), ".opencode", "openhax-codex-config.json"), ); @@ -71,6 +73,7 @@ describe("Plugin Configuration", () => { enablePromptCaching: true, enableCodexCompaction: true, autoCompactMinMessages: 8, + logging: { showWarningToasts: false }, }); }); @@ -85,6 +88,22 @@ describe("Plugin Configuration", () => { enablePromptCaching: true, enableCodexCompaction: true, autoCompactMinMessages: 8, + logging: { showWarningToasts: false }, + }); + }); + + it("should merge nested logging config with defaults", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ logging: { enableRequestLogging: false, logMaxFiles: 2 } }), + ); + + const config = loadPluginConfig({ forceReload: true }); + + expect(config.logging).toEqual({ + enableRequestLogging: false, + logMaxFiles: 2, + showWarningToasts: false, }); }); @@ -100,6 +119,7 @@ describe("Plugin Configuration", () => { enablePromptCaching: true, enableCodexCompaction: true, autoCompactMinMessages: 8, + logging: { showWarningToasts: false }, }); expect(logWarnSpy).toHaveBeenCalled(); logWarnSpy.mockRestore(); @@ -119,6 +139,7 @@ describe("Plugin Configuration", () => { enablePromptCaching: true, enableCodexCompaction: true, autoCompactMinMessages: 8, + logging: { showWarningToasts: false }, }); expect(logWarnSpy).toHaveBeenCalled(); logWarnSpy.mockRestore(); From 6152bfcbd63304bbf07a792f9a771429148de380 Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 15:08:05 -0600 Subject: [PATCH 02/17] docs: clarify install and plugin settings --- README.md | 313 ++++++++++++++++++++++------------------- config/README.md | 6 +- docs/README.md | 2 +- spec/readme-cleanup.md | 47 +++++++ 4 files changed, 225 insertions(+), 143 deletions(-) create mode 100644 spec/readme-cleanup.md diff --git a/README.md b/README.md index 68e87da..59947de 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,65 @@ This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro > **Maintained by Open Hax.** Follow project updates at [github.com/open-hax/codex](https://github.com/open-hax/codex) and report issues or ideas there. -## ⚠️ Terms of Service & Usage Notice +## Installation -**Important:** This plugin is designed for **personal development use only** with your own ChatGPT Plus/Pro subscription. By using this tool, you agree to: +- **Prerequisites:** ChatGPT Plus or Pro subscription; OpenCode installed ([opencode.ai](https://opencode.ai)); Node.js 18+. -- ✅ Use only for individual productivity and coding assistance -- ✅ Respect OpenAI's rate limits and usage policies -- ✅ Not use to power commercial services or resell access -- ✅ Comply with [OpenAI's Terms of Use](https://openai.com/policies/terms-of-use/) and [Usage Policies](https://openai.com/policies/usage-policies/) +**Quick start (minimal provider config — one model):** -**This tool uses OpenAI's official OAuth authentication** (the same method as OpenAI's official Codex CLI). However, users are responsible for ensuring their usage complies with OpenAI's terms. +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@openhax/codex"], + "model": "openai/gpt-5.1-codex-max", + "provider": { + "openai": { + "options": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium", + "include": ["reasoning.encrypted_content"], + "store": false + }, + "models": { + "gpt-5.1-codex-max": { + "name": "GPT 5.1 Codex Max (OAuth)" + } + } + } + } +} +``` -### ⚠️ Not Suitable For: -- Commercial API resale or white-labeling -- High-volume automated extraction beyond personal use -- Applications serving multiple users with one subscription -- Any use that violates OpenAI's acceptable use policies +1. Save that to `~/.config/opencode/opencode.json` (or project-specific `.opencode.json`). +2. Restart OpenCode (it installs plugins automatically). If prompted, run `opencode auth login` and finish the OAuth flow with your ChatGPT account. +3. In the TUI, choose `GPT 5.1 Codex Max (OAuth)` and start chatting. -**For production applications or commercial use, use the [OpenAI Platform API](https://platform.openai.com/) with proper API keys.** +Prefer every preset? Copy [`config/full-opencode.json`](./config/full-opencode.json) instead; it registers all GPT-5.1/GPT-5 Codex variants with recommended settings. + +Want to customize? Jump to [Configuration reference](#configuration-reference). + +## Plugin-Level Settings + +Set these in `~/.opencode/openhax-codex-config.json` (applies to all models): + +- `codexMode` (default `true`): enable the Codex ↔ OpenCode bridge prompt and tool remapping +- `enablePromptCaching` (default `true`): keep a stable `prompt_cache_key` so Codex can reuse cached prompts +- `enableCodexCompaction` (default `true`): allow `/codex-compact` behavior once upstream support lands +- `autoCompactTokenLimit` (optional): trigger Codex compaction after an approximate token threshold +- `autoCompactMinMessages` (default `8`): minimum conversation turns before auto-compaction is considered + +Example: + +```json +{ + "codexMode": true, + "enablePromptCaching": true, + "enableCodexCompaction": true, + "autoCompactTokenLimit": 120000, + "autoCompactMinMessages": 8 +} +``` --- @@ -44,25 +85,12 @@ This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro - ✅ **Usage-aware errors** - Shows clear guidance when ChatGPT subscription limits are reached - ✅ **Type-safe & tested** - Strict TypeScript with 160+ unit tests + 14 integration tests - ✅ **Modular architecture** - Easy to maintain and extend -**Prompt caching is enabled by default** to optimize your token usage and reduce costs. - -### Built-in Codex Commands - -These commands are typed as normal chat messages (no slash required). `codex-metrics`/`codex-inspect` run entirely inside the plugin. `codex-compact` issues a Codex summarization request, stores the summary, and trims future turns to keep prompts short. - -| Command | Aliases | Description | -|---------|---------|-------------| -| `codex-metrics` | `?codex-metrics`, `codexmetrics`, `/codex-metrics`* | Shows cache stats, recent prompt-cache sessions, and cache-warm status | -| `codex-inspect` | `?codex-inspect`, `codexinspect`, `/codex-inspect`* | Dumps the pending request configuration (model, prompt cache key, tools, reasoning/text settings) | -| `codex-compact` | `/codex-compact`, `compact`, `codexcompact` | Runs the Codex CLI compaction flow: summarizes the current conversation, replies with the summary, and resets Codex-side context to that summary | -> \*Slash-prefixed variants only work in environments that allow arbitrary `/` commands. In the opencode TUI, stick to `codex-metrics` / `codex-inspect` / `codex-compact` so the message is treated as normal chat text. - -**Auto compaction:** Configure `autoCompactTokenLimit`/`autoCompactMinMessages` in `~/.opencode/openhax-codex-config.json` to run compaction automatically when conversations grow long. When triggered, the plugin replies with the Codex summary and a note reminding you to resend the paused instruction; subsequent turns start from that summary instead of the entire backlog. +**Prompt caching is enabled by default** to optimize your token usage and reduce costs. ### How Caching Works -- **Enabled by default**: `enablePromptCaching: true` +- **Enabled by default**: `enablePromptCaching: true` - **GPT-5.1 models** leverage OpenAI's extended 24-hour prompt cache retention window for cheaper follow-ups - **Maintains conversation context** across multiple turns - **Reduces token consumption** by reusing cached prompts @@ -75,21 +103,18 @@ These commands are typed as normal chat messages (no slash required). `codex-met For the complete experience with all reasoning variants matching the official Codex CLI: 1. **Copy the full configuration** from [`config/full-opencode.json`](./config/full-opencode.json) to your opencode config file: + ```json { "$schema": "https://opencode.ai/config.json", - "plugin": [ - "@openhax/codex" - ], + "plugin": ["@openhax/codex"], "provider": { "openai": { "options": { "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false }, "models": { @@ -103,9 +128,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -119,9 +142,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -135,9 +156,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -151,9 +170,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -167,9 +184,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -183,9 +198,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -199,9 +212,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "none", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -215,9 +226,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -231,9 +240,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -247,9 +254,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "high", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -263,9 +268,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -279,9 +282,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -295,9 +296,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -311,9 +310,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -327,9 +324,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -343,9 +338,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "minimal", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -359,9 +352,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -375,9 +366,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -391,9 +380,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "high", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -407,9 +394,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -423,9 +408,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "minimal", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } } @@ -435,12 +418,12 @@ For the complete experience with all reasoning variants matching the official Co } ``` - **Global config**: `~/.config/opencode/opencode.json` - **Project config**: `/.opencode.json` +**Global config**: `~/.config/opencode/opencode.json` +**Project config**: `/.opencode.json` - This now gives you 21 model variants: the refreshed GPT-5.1 lineup (with Codex Max as the default) plus every legacy gpt-5 preset for backwards compatibility. +This now gives you 21 model variants: the refreshed GPT-5.1 lineup (with Codex Max as the default) plus every legacy gpt-5 preset for backwards compatibility. - All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5 High (OAuth)", etc. +All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5 High (OAuth)", etc. ### Available Model Variants (Full Config) @@ -448,25 +431,25 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t #### GPT-5.1 lineup (recommended) -| CLI Model ID | TUI Display Name | Reasoning Effort | Best For | -|--------------|------------------|-----------------|----------| -| `gpt-5.1-codex-max` | GPT 5.1 Codex Max (OAuth) | Low/Medium/High/**Extra High** | Default flagship tier with `xhigh` reasoning for complex, multi-step problems | -| `gpt-5.1-codex-low` | GPT 5.1 Codex Low (OAuth) | Low | Fast code generation on the newest Codex tier | -| `gpt-5.1-codex-medium` | GPT 5.1 Codex Medium (OAuth) | Medium | Balanced code + tooling workflows | -| `gpt-5.1-codex-high` | GPT 5.1 Codex High (OAuth) | High | Multi-step coding tasks with deep tool use | -| `gpt-5.1-codex-mini-medium` | GPT 5.1 Codex Mini Medium (OAuth) | Medium | Budget-friendly Codex runs (200k/100k tokens) | -| `gpt-5.1-codex-mini-high` | GPT 5.1 Codex Mini High (OAuth) | High | Cheaper Codex tier with maximum reasoning | -| `gpt-5.1-none` | GPT 5.1 None (OAuth) | **None** | Latency-sensitive chat/tasks using the "no reasoning" mode | -| `gpt-5.1-low` | GPT 5.1 Low (OAuth) | Low | Fast general-purpose chat with light reasoning | -| `gpt-5.1-medium` | GPT 5.1 Medium (OAuth) | Medium | Default adaptive reasoning for everyday work | -| `gpt-5.1-high` | GPT 5.1 High (OAuth) | High | Deep analysis when reliability matters most | +| CLI Model ID | TUI Display Name | Reasoning Effort | Best For | +| --------------------------- | --------------------------------- | ------------------------------ | ----------------------------------------------------------------------------- | +| `gpt-5.1-codex-max` | GPT 5.1 Codex Max (OAuth) | Low/Medium/High/**Extra High** | Default flagship tier with `xhigh` reasoning for complex, multi-step problems | +| `gpt-5.1-codex-low` | GPT 5.1 Codex Low (OAuth) | Low | Fast code generation on the newest Codex tier | +| `gpt-5.1-codex-medium` | GPT 5.1 Codex Medium (OAuth) | Medium | Balanced code + tooling workflows | +| `gpt-5.1-codex-high` | GPT 5.1 Codex High (OAuth) | High | Multi-step coding tasks with deep tool use | +| `gpt-5.1-codex-mini-medium` | GPT 5.1 Codex Mini Medium (OAuth) | Medium | Budget-friendly Codex runs (200k/100k tokens) | +| `gpt-5.1-codex-mini-high` | GPT 5.1 Codex Mini High (OAuth) | High | Cheaper Codex tier with maximum reasoning | +| `gpt-5.1-none` | GPT 5.1 None (OAuth) | **None** | Latency-sensitive chat/tasks using the "no reasoning" mode | +| `gpt-5.1-low` | GPT 5.1 Low (OAuth) | Low | Fast general-purpose chat with light reasoning | +| `gpt-5.1-medium` | GPT 5.1 Medium (OAuth) | Medium | Default adaptive reasoning for everyday work | +| `gpt-5.1-high` | GPT 5.1 High (OAuth) | High | Deep analysis when reliability matters most | > **Extra High reasoning:** `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is exclusive to `gpt-5.1-codex-max`. Other models automatically map that option to `high` so their API calls remain valid. #### Legacy GPT-5 lineup (still supported) | CLI Model ID | TUI Display Name | Reasoning Effort | Best For | -|--------------|------------------|-----------------|----------| +| ------------ | ---------------- | ---------------- | -------- | | `gpt-5-codex-low` | GPT 5 Codex Low (OAuth) | Low | Fast code generation | | `gpt-5-codex-medium` | GPT 5 Codex Medium (OAuth) | Medium | Balanced code tasks | @@ -519,11 +502,44 @@ When no configuration is specified, the plugin uses these defaults for all GPT-5 These defaults match the official Codex CLI behavior and can be customized (see Configuration below). GPT-5.1 requests automatically start at `reasoningEffort: "none"`, while Codex/Codex Mini presets continue to clamp to their supported levels. -## Configuration +## Configuration Reference + +Already set up from Installation? You're all set. Use this section when you want to tweak defaults or build custom presets. + +### Minimal configuration (single model) -### Recommended: Use Pre-Configured File +Use the smallest working provider config if you only need one flagship model: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@openhax/codex"], + "model": "openai/gpt-5.1-codex-max", + "provider": { + "openai": { + "options": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium", + "include": ["reasoning.encrypted_content"], + "store": false + }, + "models": { + "gpt-5.1-codex-max": { + "name": "GPT 5.1 Codex Max (OAuth)" + } + } + } + } +} +``` + +`gpt-5.1-codex-max` is the recommended default for balanced reasoning + tool use. Switch the `model` value if you prefer another preset. + +### Full preset bundle + +The easiest way to get all presets is to use [`config/full-opencode.json`](./config/full-opencode.json), which provides: -The easiest way to get started is to use [`config/full-opencode.json`](./config/full-opencode.json), which provides: - 21 pre-configured model variants matching the latest Codex CLI presets (GPT-5.1 Codex Max + GPT-5.1 + GPT-5) - Optimal settings for each reasoning level - All variants visible in the opencode model selector @@ -538,26 +554,18 @@ If you want to customize settings yourself, you can configure options at provide ⚠️ **Important**: The two base models have different supported values. -| Setting | GPT-5 / GPT-5.1 Values | GPT-5-Codex / Codex Mini Values | Plugin Default | -|---------|-------------|-------------------|----------------| -| `reasoningEffort` | `none`, `minimal`, `low`, `medium`, `high` | `low`, `medium`, `high`, `xhigh`† | `medium` | -| `reasoningSummary` | `auto`, `detailed` | `auto`, `detailed` | `auto` | -| `textVerbosity` | `low`, `medium`, `high` | `medium` only | `medium` | -| `include` | Array of strings | Array of strings | `["reasoning.encrypted_content"]` | +| Setting | GPT-5 / GPT-5.1 Values | GPT-5-Codex / Codex Mini Values | Plugin Default | +| ------------------ | ------------------------------------------ | --------------------------------- | --------------------------------- | +| `reasoningEffort` | `none`, `minimal`, `low`, `medium`, `high` | `low`, `medium`, `high`, `xhigh`† | `medium` | +| `reasoningSummary` | `auto`, `detailed` | `auto`, `detailed` | `auto` | +| `textVerbosity` | `low`, `medium`, `high` | `medium` only | `medium` | +| `include` | Array of strings | Array of strings | `["reasoning.encrypted_content"]` | > **Note**: `minimal` effort is auto-normalized to `low` for gpt-5-codex (not supported by the API). `none` is only supported on GPT-5.1 general models; when used with legacy gpt-5 it is normalized to `minimal`. `xhigh` is exclusive to `gpt-5.1-codex-max`—other Codex presets automatically map it to `high`. -> +> > † **Extra High reasoning**: `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is only available on `gpt-5.1-codex-max`. -#### Plugin-Level Settings - -Set these in `~/.opencode/openhax-codex-config.json`: - -- `codexMode` (default `true`): enable the Codex ↔ OpenCode bridge prompt -- `enablePromptCaching` (default `true`): keep a stable `prompt_cache_key` and preserved message IDs so Codex can reuse cached prompts, reducing token usage and costs -- `enableCodexCompaction` (default `true`): expose `/codex-compact` and allow the plugin to rewrite history based on Codex summaries -- `autoCompactTokenLimit` (default unset): when set, triggers Codex compaction once the approximate token count exceeds this value -- `autoCompactMinMessages` (default `8`): minimum number of conversation turns before auto-compaction is considered +See [Plugin-Level Settings](#plugin-level-settings) above for global toggles. Below are provider/model examples. #### Global Configuration Example @@ -636,6 +644,7 @@ This plugin respects the same rate limits enforced by OpenAI's official Codex CL - **The plugin does NOT and CANNOT bypass** OpenAI's rate limits ### Best Practices: + - ✅ Use for individual coding tasks, not bulk processing - ✅ Avoid rapid-fire automated requests - ✅ Monitor your usage to stay within subscription limits @@ -647,11 +656,6 @@ This plugin respects the same rate limits enforced by OpenAI's official Codex CL --- -## Requirements - -- **ChatGPT Plus or Pro subscription** (required) -- **OpenCode** installed ([opencode.ai](https://opencode.ai)) - ## Updating & Clearing Caches OpenCode caches plugins under `~/.cache/opencode` and stores Codex-specific assets (prompt-warm files, instruction caches, logs) under `~/.opencode`. When this plugin ships a new release, clear both locations so OpenCode reinstalls the latest bits and the warmed prompts align with the new version. @@ -680,7 +684,6 @@ OpenCode caches plugins under `~/.cache/opencode` and stores Codex-specific asse ## Debug Mode - Enable detailed logging: ```bash @@ -704,6 +707,7 @@ See [Troubleshooting Guide](https://open-hax.github.io/codex/troubleshooting) fo This plugin uses **OpenAI's official OAuth authentication** (the same method as their official Codex CLI). It's designed for personal coding assistance with your own ChatGPT subscription. However, **users are responsible for ensuring their usage complies with OpenAI's Terms of Use**. This means: + - Personal use for your own development - Respecting rate limits - Not reselling access or powering commercial services @@ -720,12 +724,14 @@ For commercial applications, production systems, or services serving multiple us Using OAuth authentication for personal coding assistance aligns with OpenAI's official Codex CLI use case. However, violating OpenAI's terms could result in account action: **Safe use:** + - Personal coding assistance - Individual productivity - Legitimate development work - Respecting rate limits **Risky use:** + - Commercial resale of access - Powering multi-user services - High-volume automated extraction @@ -734,6 +740,7 @@ Using OAuth authentication for personal coding assistance aligns with OpenAI's o ### What's the difference between this and scraping session tokens? **Critical distinction:** + - ✅ **This plugin:** Uses official OAuth authentication through OpenAI's authorization server - ❌ **Session scraping:** Extracts cookies/tokens from browsers (clearly violates TOS) @@ -758,10 +765,11 @@ ChatGPT, GPT-5, and Codex are trademarks of OpenAI. **Prompt caching is enabled by default** to save you money: - **Reduces token usage** by reusing conversation context across turns -- **Lowers costs** significantly for multi-turn conversations +- **Lowers costs** significantly for multi-turn conversations - **Maintains context** so the AI remembers previous parts of your conversation You can disable it by creating `~/.opencode/openhax-codex-config.json` with: + ```json { "enablePromptCaching": false @@ -775,12 +783,14 @@ You can disable it by creating `~/.opencode/openhax-codex-config.json` with: ## Credits & Attribution This plugin implements OAuth authentication for OpenAI's Codex backend, using the same authentication flow as: + - [OpenAI's official Codex CLI](https://github.com/openai/codex) - OpenAI's OAuth authorization server (https://chatgpt.com/oauth) ### Acknowledgments Based on research and working implementations from: + - [ben-vargas/ai-sdk-provider-chatgpt-oauth](https://github.com/ben-vargas/ai-sdk-provider-chatgpt-oauth) - [ben-vargas/ai-opencode-chatgpt-auth](https://github.com/ben-vargas/ai-opencode-chatgpt-auth) - [openai/codex](https://github.com/openai/codex) OAuth flow @@ -795,12 +805,33 @@ Based on research and working implementations from: ## Documentation **📖 Documentation:** + - [Installation](#installation) - Get started in 2 minutes -- [Configuration](#configuration) - Customize your setup +- [Configuration reference](#configuration-reference) - Customize your setup - [Troubleshooting](#troubleshooting) - Common issues - [GitHub Pages Docs](https://open-hax.github.io/codex/) - Extended guides - [Developer Docs](https://open-hax.github.io/codex/development/ARCHITECTURE) - Technical deep dive +## Terms of Service & Usage Notice + +**Important:** This plugin is designed for **personal development use only** with your own ChatGPT Plus/Pro subscription. By using this tool, you agree to: + +- ✅ Use only for individual productivity and coding assistance +- ✅ Respect OpenAI's rate limits and usage policies +- ✅ Not use to power commercial services or resell access +- ✅ Comply with [OpenAI's Terms of Use](https://openai.com/policies/terms-of-use/) and [Usage Policies](https://openai.com/policies/usage-policies/) + +**This tool uses OpenAI's official OAuth authentication** (the same method as OpenAI's official Codex CLI). However, users are responsible for ensuring their usage complies with OpenAI's terms. + +### ⚠️ Not Suitable For: + +- Commercial API resale or white-labeling +- High-volume automated extraction beyond personal use +- Applications serving multiple users with one subscription +- Any use that violates OpenAI's acceptable use policies + +**For production applications or commercial use, use the [OpenAI Platform API](https://platform.openai.com/) with proper API keys.** + ## License GPL-3.0 — see [LICENSE](./LICENSE) for details. diff --git a/config/README.md b/config/README.md index 7a4ea7c..4c297c2 100644 --- a/config/README.md +++ b/config/README.md @@ -5,6 +5,7 @@ This directory contains example opencode configuration files for the OpenAI Code ## Files ### minimal-opencode.json + The simplest possible configuration using plugin defaults. ```bash @@ -12,6 +13,7 @@ cp config/minimal-opencode.json ~/.config/opencode/opencode.json ``` This uses default settings: + - `reasoningEffort`: "medium" - `reasoningSummary`: "auto" - `textVerbosity`: "medium" @@ -19,6 +21,7 @@ This uses default settings: - `store`: false (required for AI SDK 2.0.50+ compatibility) ### full-opencode.json + Complete configuration example showing all model variants with custom settings. ```bash @@ -26,6 +29,7 @@ cp config/full-opencode.json ~/.config/opencode/opencode.json ``` This demonstrates: + - Global options for all models - Per-model configuration overrides - All supported model variants (gpt-5-codex, gpt-5-codex-mini, gpt-5, gpt-5-mini, gpt-5-nano) @@ -41,4 +45,4 @@ This demonstrates: ## Configuration Options -See the main [README.md](../README.md#configuration) for detailed documentation of all configuration options. +See the main [README.md](../README.md#configuration-reference) for detailed documentation of all configuration options. diff --git a/docs/README.md b/docs/README.md index 4e91055..36f111d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ Welcome to the OpenHax Codex Plugin documentation! ## For Users - **[Getting Started](../README.md)** - Installation, configuration, and quick start -- **[Configuration Guide](../README.md#configuration)** - Complete config reference +- **[Configuration Guide](../README.md#configuration-reference)** - Complete config reference - **[Troubleshooting](../README.md#troubleshooting)** - Common issues and debugging - **[Changelog](../CHANGELOG.md)** - Version history and release notes diff --git a/spec/readme-cleanup.md b/spec/readme-cleanup.md new file mode 100644 index 0000000..a95f7ce --- /dev/null +++ b/spec/readme-cleanup.md @@ -0,0 +1,47 @@ +# README cleanup and installation clarity + +**Date**: 2025-11-21 +**Owner**: Codex agent +**Goal**: Make README quieter, surface installation guidance, and move TOS to the bottom. + +## Code touchpoints (current line refs) + +- `README.md:11-62` — Installation with minimal provider config (plugin + single model + provider options/models) emphasized first. +- `README.md:64-96` — Plugin-Level Settings section surfaced right after installation. +- `README.md:451-522` — Plugin defaults and Configuration Reference intro include minimal provider config subsection (no duplicated plugin-level settings here; points back to top section). +- `README.md:51-68` — Removed non-functional Built-in Codex Commands section. +- `README.md:737-745` — Documentation links reordered above TOS. +- `README.md:747-764` — Terms of Service & Usage Notice relocated near bottom. +- `README.md:770-783` — Auto-generated package doc matrix; must remain untouched. + +## Existing issues / PRs + +- None found for README noise/installation confusion (quick repository scan; no linked issue/PR identified). + +## Definition of done + +- TOS/usage notice sits near the end (after FAQs/Docs, before License or similar closing content). +- Clear "Installation" section early that explains prerequisites and setup steps distinct from configuration. +- Configuration content split into digestible pieces (recommended config vs custom/advanced) with concise intros. +- Overall README flow reduces upfront noise while preserving key links and auto-generated matrix. + +## Requirements & constraints + +- Keep auto-generated `PACKAGE-DOC-MATRIX` block unchanged. +- Preserve existing links and accuracy of configuration examples; adjust anchors if section names change. +- Maintain guidance on ChatGPT subscription and opencode prerequisites. +- Avoid removing critical warnings; relocation is acceptable. + +## Plan / phases + +1. Draft new top-level outline (Intro, Installation, Key features/commands, Configuration split, Caching, Troubleshooting, Docs, TOS/License). +2. Rewrite README sections to match outline (minimal edits to examples, move TOS near end). +3. Quick pass for clarity/heading consistency and anchor references. + +## Changelog + +- 2025-11-21: Added Installation section, renamed Configuration Reference, removed standalone requirements block, moved TOS near bottom, and updated related anchors in docs/config README files. +- 2025-11-21: Promoted minimal provider config (plugin array + single `openai/gpt-5.1-codex-max` model with provider/openai options) to top of Installation and Configuration Reference. +- 2025-11-21: Removed non-functional Built-in Codex Commands section pending upstream support. +- 2025-11-21: Surfaced plugin-level settings (codexMode, caching, compaction) immediately after Installation with example JSON. +- 2025-11-21: Removed duplicated plugin-level settings block from Configuration Reference; now it links back to the top settings section. From aa8e1c9667f7771c0e076b4029a2848f39e9db15 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 21 Nov 2025 21:29:10 +0000 Subject: [PATCH 03/17] Fixed unsafe mutable export with getter function Co-authored-by: riatzukiza --- lib/logger.ts | 5 ++++- lib/request/response-handler.ts | 4 ++-- package-lock.json | 4 ++-- test/README.md | 2 +- test/cache-warming.test.ts | 2 +- test/logger.test.ts | 6 +++--- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/logger.ts b/lib/logger.ts index ea7efa2..4cc6d81 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -18,7 +18,10 @@ const envLoggingDefaults = { logQueueMaxLength: getEnvNumber("CODEX_LOG_QUEUE_MAX", 1000), }; -export let LOGGING_ENABLED = envLoggingDefaults.loggingEnabled; +let LOGGING_ENABLED = envLoggingDefaults.loggingEnabled; +export function isLoggingEnabled(): boolean { + return LOGGING_ENABLED; +} let DEBUG_FLAG_ENABLED = envLoggingDefaults.debugFlagEnabled; let WARN_TOASTS_ENABLED = envLoggingDefaults.showWarningToasts ?? false; let LOG_ROTATION_MAX_BYTES = Math.max(1, envLoggingDefaults.logRotationMaxBytes); diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 3dd6cc2..781e90b 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -1,5 +1,5 @@ import { PLUGIN_NAME } from "../constants.js"; -import { LOGGING_ENABLED, logError, logRequest } from "../logger.js"; +import { isLoggingEnabled, logError, logRequest } from "../logger.js"; import type { SSEEventData } from "../types.js"; /** @@ -50,7 +50,7 @@ export async function convertSseToJson(response: Response, headers: Headers): Pr fullText += decoder.decode(value, { stream: true }); } - if (LOGGING_ENABLED) { + if (isLoggingEnabled()) { logRequest("stream-full", { fullContent: fullText }); } diff --git a/package-lock.json b/package-lock.json index 2c5dc17..158f8cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openhax/codex", - "version": "0.3.5", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openhax/codex", - "version": "0.3.5", + "version": "0.4.0", "license": "GPL-3.0-only", "dependencies": { "@openauthjs/openauth": "^0.4.3", diff --git a/test/README.md b/test/README.md index 6adddfb..365a388 100644 --- a/test/README.md +++ b/test/README.md @@ -79,7 +79,7 @@ Tests SSE to JSON conversion: Tests logging functionality: -- LOGGING_ENABLED constant +- isLoggingEnabled() function - logRequest function parameter handling - Complex data structure support diff --git a/test/cache-warming.test.ts b/test/cache-warming.test.ts index 04852f3..02870d1 100644 --- a/test/cache-warming.test.ts +++ b/test/cache-warming.test.ts @@ -20,7 +20,7 @@ vi.mock("../lib/logger.js", () => ({ logDebug: vi.fn(), logWarn: vi.fn(), logRequest: vi.fn(), - LOGGING_ENABLED: false, + isLoggingEnabled: () => false, })); const mockGetCodexInstructions = getCodexInstructions as ReturnType; diff --git a/test/logger.test.ts b/test/logger.test.ts index d7d7751..8bc63f3 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -65,10 +65,10 @@ afterEach(() => { }); describe("logger", () => { - it("LOGGING_ENABLED reflects env state", async () => { + it("isLoggingEnabled reflects env state", async () => { process.env.ENABLE_PLUGIN_REQUEST_LOGGING = "1"; - const { LOGGING_ENABLED } = await import("../lib/logger.js"); - expect(LOGGING_ENABLED).toBe(true); + const { isLoggingEnabled } = await import("../lib/logger.js"); + expect(isLoggingEnabled()).toBe(true); }); it("logRequest writes stage file and rolling log when enabled", async () => { From 8618d01ef8c3bf456d77a4fe22a70f808bab60f7 Mon Sep 17 00:00:00 2001 From: Err Date: Fri, 21 Nov 2025 15:29:58 -0600 Subject: [PATCH 04/17] chore: release v0.4.1 (PR #69) (#70) Co-authored-by: github-actions[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 18f8338..722132d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openhax/codex", - "version": "0.4.0", + "version": "0.4.1", "description": "OpenHax Codex OAuth plugin for Opencode — bring your ChatGPT Plus/Pro subscription instead of API credits", "main": "./dist/index.js", "types": "./dist/index.d.ts", From d8c1039468f8cb09eeab9f577683ef4871c76e37 Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 15:35:12 -0600 Subject: [PATCH 05/17] Remove plugin-side compaction flow --- README.md | 177 +++++++++++------------------ docs/configuration.md | 49 ++++---- docs/getting-started.md | 22 +--- lib/config.ts | 2 - lib/request/codex-fetcher.ts | 12 +- lib/request/fetch-helpers.ts | 41 +------ lib/request/request-transformer.ts | 29 +---- lib/types.ts | 16 --- spec/codex-compaction.md | 73 ------------ spec/compaction-heuristics-22.md | 51 --------- spec/remove-plugin-compaction.md | 30 +++++ test/codex-compaction.test.ts | 95 ---------------- test/codex-fetcher.test.ts | 45 -------- test/compaction-executor.test.ts | 133 ---------------------- test/compaction-helpers.test.ts | 57 ---------- test/fetch-helpers.test.ts | 13 --- test/plugin-config.test.ts | 10 -- 17 files changed, 139 insertions(+), 716 deletions(-) delete mode 100644 spec/codex-compaction.md delete mode 100644 spec/compaction-heuristics-22.md create mode 100644 spec/remove-plugin-compaction.md delete mode 100644 test/codex-compaction.test.ts delete mode 100644 test/compaction-executor.test.ts delete mode 100644 test/compaction-helpers.test.ts diff --git a/README.md b/README.md index 68e87da..ab60607 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro **This tool uses OpenAI's official OAuth authentication** (the same method as OpenAI's official Codex CLI). However, users are responsible for ensuring their usage complies with OpenAI's terms. ### ⚠️ Not Suitable For: + - Commercial API resale or white-labeling - High-volume automated extraction beyond personal use - Applications serving multiple users with one subscription @@ -44,25 +45,22 @@ This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro - ✅ **Usage-aware errors** - Shows clear guidance when ChatGPT subscription limits are reached - ✅ **Type-safe & tested** - Strict TypeScript with 160+ unit tests + 14 integration tests - ✅ **Modular architecture** - Easy to maintain and extend -**Prompt caching is enabled by default** to optimize your token usage and reduce costs. + **Prompt caching is enabled by default** to optimize your token usage and reduce costs. ### Built-in Codex Commands -These commands are typed as normal chat messages (no slash required). `codex-metrics`/`codex-inspect` run entirely inside the plugin. `codex-compact` issues a Codex summarization request, stores the summary, and trims future turns to keep prompts short. - -| Command | Aliases | Description | -|---------|---------|-------------| -| `codex-metrics` | `?codex-metrics`, `codexmetrics`, `/codex-metrics`* | Shows cache stats, recent prompt-cache sessions, and cache-warm status | -| `codex-inspect` | `?codex-inspect`, `codexinspect`, `/codex-inspect`* | Dumps the pending request configuration (model, prompt cache key, tools, reasoning/text settings) | -| `codex-compact` | `/codex-compact`, `compact`, `codexcompact` | Runs the Codex CLI compaction flow: summarizes the current conversation, replies with the summary, and resets Codex-side context to that summary | +These commands are typed as normal chat messages (no slash required). `codex-metrics`/`codex-inspect` run entirely inside the plugin. -> \*Slash-prefixed variants only work in environments that allow arbitrary `/` commands. In the opencode TUI, stick to `codex-metrics` / `codex-inspect` / `codex-compact` so the message is treated as normal chat text. +| Command | Aliases | Description | +| --------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `codex-metrics` | `?codex-metrics`, `codexmetrics`, `/codex-metrics`\* | Shows cache stats, recent prompt-cache sessions, and cache-warm status | +| `codex-inspect` | `?codex-inspect`, `codexinspect`, `/codex-inspect`\* | Dumps the pending request configuration (model, prompt cache key, tools, reasoning/text settings) | -**Auto compaction:** Configure `autoCompactTokenLimit`/`autoCompactMinMessages` in `~/.opencode/openhax-codex-config.json` to run compaction automatically when conversations grow long. When triggered, the plugin replies with the Codex summary and a note reminding you to resend the paused instruction; subsequent turns start from that summary instead of the entire backlog. +> \*Slash-prefixed variants only work in environments that allow arbitrary `/` commands. In the opencode TUI, stick to `codex-metrics` / `codex-inspect` so the message is treated as normal chat text. ### How Caching Works -- **Enabled by default**: `enablePromptCaching: true` +- **Enabled by default**: `enablePromptCaching: true` - **GPT-5.1 models** leverage OpenAI's extended 24-hour prompt cache retention window for cheaper follow-ups - **Maintains conversation context** across multiple turns - **Reduces token consumption** by reusing cached prompts @@ -75,21 +73,18 @@ These commands are typed as normal chat messages (no slash required). `codex-met For the complete experience with all reasoning variants matching the official Codex CLI: 1. **Copy the full configuration** from [`config/full-opencode.json`](./config/full-opencode.json) to your opencode config file: + ```json { "$schema": "https://opencode.ai/config.json", - "plugin": [ - "@openhax/codex" - ], + "plugin": ["@openhax/codex"], "provider": { "openai": { "options": { "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false }, "models": { @@ -103,9 +98,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -119,9 +112,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -135,9 +126,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -151,9 +140,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -167,9 +154,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -183,9 +168,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -199,9 +182,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "none", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -215,9 +196,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -231,9 +210,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -247,9 +224,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "high", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -263,9 +238,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -279,9 +252,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -295,9 +266,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -311,9 +280,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -327,9 +294,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -343,9 +308,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "minimal", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -359,9 +322,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -375,9 +336,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -391,9 +350,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "high", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -407,9 +364,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -423,9 +378,7 @@ For the complete experience with all reasoning variants matching the official Co "reasoningEffort": "minimal", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } } @@ -435,12 +388,12 @@ For the complete experience with all reasoning variants matching the official Co } ``` - **Global config**: `~/.config/opencode/opencode.json` - **Project config**: `/.opencode.json` +**Global config**: `~/.config/opencode/opencode.json` +**Project config**: `/.opencode.json` - This now gives you 21 model variants: the refreshed GPT-5.1 lineup (with Codex Max as the default) plus every legacy gpt-5 preset for backwards compatibility. +This now gives you 21 model variants: the refreshed GPT-5.1 lineup (with Codex Max as the default) plus every legacy gpt-5 preset for backwards compatibility. - All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5 High (OAuth)", etc. +All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5 High (OAuth)", etc. ### Available Model Variants (Full Config) @@ -448,25 +401,25 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t #### GPT-5.1 lineup (recommended) -| CLI Model ID | TUI Display Name | Reasoning Effort | Best For | -|--------------|------------------|-----------------|----------| -| `gpt-5.1-codex-max` | GPT 5.1 Codex Max (OAuth) | Low/Medium/High/**Extra High** | Default flagship tier with `xhigh` reasoning for complex, multi-step problems | -| `gpt-5.1-codex-low` | GPT 5.1 Codex Low (OAuth) | Low | Fast code generation on the newest Codex tier | -| `gpt-5.1-codex-medium` | GPT 5.1 Codex Medium (OAuth) | Medium | Balanced code + tooling workflows | -| `gpt-5.1-codex-high` | GPT 5.1 Codex High (OAuth) | High | Multi-step coding tasks with deep tool use | -| `gpt-5.1-codex-mini-medium` | GPT 5.1 Codex Mini Medium (OAuth) | Medium | Budget-friendly Codex runs (200k/100k tokens) | -| `gpt-5.1-codex-mini-high` | GPT 5.1 Codex Mini High (OAuth) | High | Cheaper Codex tier with maximum reasoning | -| `gpt-5.1-none` | GPT 5.1 None (OAuth) | **None** | Latency-sensitive chat/tasks using the "no reasoning" mode | -| `gpt-5.1-low` | GPT 5.1 Low (OAuth) | Low | Fast general-purpose chat with light reasoning | -| `gpt-5.1-medium` | GPT 5.1 Medium (OAuth) | Medium | Default adaptive reasoning for everyday work | -| `gpt-5.1-high` | GPT 5.1 High (OAuth) | High | Deep analysis when reliability matters most | +| CLI Model ID | TUI Display Name | Reasoning Effort | Best For | +| --------------------------- | --------------------------------- | ------------------------------ | ----------------------------------------------------------------------------- | +| `gpt-5.1-codex-max` | GPT 5.1 Codex Max (OAuth) | Low/Medium/High/**Extra High** | Default flagship tier with `xhigh` reasoning for complex, multi-step problems | +| `gpt-5.1-codex-low` | GPT 5.1 Codex Low (OAuth) | Low | Fast code generation on the newest Codex tier | +| `gpt-5.1-codex-medium` | GPT 5.1 Codex Medium (OAuth) | Medium | Balanced code + tooling workflows | +| `gpt-5.1-codex-high` | GPT 5.1 Codex High (OAuth) | High | Multi-step coding tasks with deep tool use | +| `gpt-5.1-codex-mini-medium` | GPT 5.1 Codex Mini Medium (OAuth) | Medium | Budget-friendly Codex runs (200k/100k tokens) | +| `gpt-5.1-codex-mini-high` | GPT 5.1 Codex Mini High (OAuth) | High | Cheaper Codex tier with maximum reasoning | +| `gpt-5.1-none` | GPT 5.1 None (OAuth) | **None** | Latency-sensitive chat/tasks using the "no reasoning" mode | +| `gpt-5.1-low` | GPT 5.1 Low (OAuth) | Low | Fast general-purpose chat with light reasoning | +| `gpt-5.1-medium` | GPT 5.1 Medium (OAuth) | Medium | Default adaptive reasoning for everyday work | +| `gpt-5.1-high` | GPT 5.1 High (OAuth) | High | Deep analysis when reliability matters most | > **Extra High reasoning:** `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is exclusive to `gpt-5.1-codex-max`. Other models automatically map that option to `high` so their API calls remain valid. #### Legacy GPT-5 lineup (still supported) | CLI Model ID | TUI Display Name | Reasoning Effort | Best For | -|--------------|------------------|-----------------|----------| +| ------------ | ---------------- | ---------------- | -------- | | `gpt-5-codex-low` | GPT 5 Codex Low (OAuth) | Low | Fast code generation | | `gpt-5-codex-medium` | GPT 5 Codex Medium (OAuth) | Medium | Balanced code tasks | @@ -524,6 +477,7 @@ These defaults match the official Codex CLI behavior and can be customized (see ### Recommended: Use Pre-Configured File The easiest way to get started is to use [`config/full-opencode.json`](./config/full-opencode.json), which provides: + - 21 pre-configured model variants matching the latest Codex CLI presets (GPT-5.1 Codex Max + GPT-5.1 + GPT-5) - Optimal settings for each reasoning level - All variants visible in the opencode model selector @@ -538,15 +492,15 @@ If you want to customize settings yourself, you can configure options at provide ⚠️ **Important**: The two base models have different supported values. -| Setting | GPT-5 / GPT-5.1 Values | GPT-5-Codex / Codex Mini Values | Plugin Default | -|---------|-------------|-------------------|----------------| -| `reasoningEffort` | `none`, `minimal`, `low`, `medium`, `high` | `low`, `medium`, `high`, `xhigh`† | `medium` | -| `reasoningSummary` | `auto`, `detailed` | `auto`, `detailed` | `auto` | -| `textVerbosity` | `low`, `medium`, `high` | `medium` only | `medium` | -| `include` | Array of strings | Array of strings | `["reasoning.encrypted_content"]` | +| Setting | GPT-5 / GPT-5.1 Values | GPT-5-Codex / Codex Mini Values | Plugin Default | +| ------------------ | ------------------------------------------ | --------------------------------- | --------------------------------- | +| `reasoningEffort` | `none`, `minimal`, `low`, `medium`, `high` | `low`, `medium`, `high`, `xhigh`† | `medium` | +| `reasoningSummary` | `auto`, `detailed` | `auto`, `detailed` | `auto` | +| `textVerbosity` | `low`, `medium`, `high` | `medium` only | `medium` | +| `include` | Array of strings | Array of strings | `["reasoning.encrypted_content"]` | > **Note**: `minimal` effort is auto-normalized to `low` for gpt-5-codex (not supported by the API). `none` is only supported on GPT-5.1 general models; when used with legacy gpt-5 it is normalized to `minimal`. `xhigh` is exclusive to `gpt-5.1-codex-max`—other Codex presets automatically map it to `high`. -> +> > † **Extra High reasoning**: `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is only available on `gpt-5.1-codex-max`. #### Plugin-Level Settings @@ -555,9 +509,6 @@ Set these in `~/.opencode/openhax-codex-config.json`: - `codexMode` (default `true`): enable the Codex ↔ OpenCode bridge prompt - `enablePromptCaching` (default `true`): keep a stable `prompt_cache_key` and preserved message IDs so Codex can reuse cached prompts, reducing token usage and costs -- `enableCodexCompaction` (default `true`): expose `/codex-compact` and allow the plugin to rewrite history based on Codex summaries -- `autoCompactTokenLimit` (default unset): when set, triggers Codex compaction once the approximate token count exceeds this value -- `autoCompactMinMessages` (default `8`): minimum number of conversation turns before auto-compaction is considered #### Global Configuration Example @@ -636,6 +587,7 @@ This plugin respects the same rate limits enforced by OpenAI's official Codex CL - **The plugin does NOT and CANNOT bypass** OpenAI's rate limits ### Best Practices: + - ✅ Use for individual coding tasks, not bulk processing - ✅ Avoid rapid-fire automated requests - ✅ Monitor your usage to stay within subscription limits @@ -680,7 +632,6 @@ OpenCode caches plugins under `~/.cache/opencode` and stores Codex-specific asse ## Debug Mode - Enable detailed logging: ```bash @@ -704,6 +655,7 @@ See [Troubleshooting Guide](https://open-hax.github.io/codex/troubleshooting) fo This plugin uses **OpenAI's official OAuth authentication** (the same method as their official Codex CLI). It's designed for personal coding assistance with your own ChatGPT subscription. However, **users are responsible for ensuring their usage complies with OpenAI's Terms of Use**. This means: + - Personal use for your own development - Respecting rate limits - Not reselling access or powering commercial services @@ -720,12 +672,14 @@ For commercial applications, production systems, or services serving multiple us Using OAuth authentication for personal coding assistance aligns with OpenAI's official Codex CLI use case. However, violating OpenAI's terms could result in account action: **Safe use:** + - Personal coding assistance - Individual productivity - Legitimate development work - Respecting rate limits **Risky use:** + - Commercial resale of access - Powering multi-user services - High-volume automated extraction @@ -734,6 +688,7 @@ Using OAuth authentication for personal coding assistance aligns with OpenAI's o ### What's the difference between this and scraping session tokens? **Critical distinction:** + - ✅ **This plugin:** Uses official OAuth authentication through OpenAI's authorization server - ❌ **Session scraping:** Extracts cookies/tokens from browsers (clearly violates TOS) @@ -758,10 +713,11 @@ ChatGPT, GPT-5, and Codex are trademarks of OpenAI. **Prompt caching is enabled by default** to save you money: - **Reduces token usage** by reusing conversation context across turns -- **Lowers costs** significantly for multi-turn conversations +- **Lowers costs** significantly for multi-turn conversations - **Maintains context** so the AI remembers previous parts of your conversation You can disable it by creating `~/.opencode/openhax-codex-config.json` with: + ```json { "enablePromptCaching": false @@ -775,12 +731,14 @@ You can disable it by creating `~/.opencode/openhax-codex-config.json` with: ## Credits & Attribution This plugin implements OAuth authentication for OpenAI's Codex backend, using the same authentication flow as: + - [OpenAI's official Codex CLI](https://github.com/openai/codex) - OpenAI's OAuth authorization server (https://chatgpt.com/oauth) ### Acknowledgments Based on research and working implementations from: + - [ben-vargas/ai-sdk-provider-chatgpt-oauth](https://github.com/ben-vargas/ai-sdk-provider-chatgpt-oauth) - [ben-vargas/ai-opencode-chatgpt-auth](https://github.com/ben-vargas/ai-opencode-chatgpt-auth) - [openai/codex](https://github.com/openai/codex) OAuth flow @@ -795,6 +753,7 @@ Based on research and working implementations from: ## Documentation **📖 Documentation:** + - [Installation](#installation) - Get started in 2 minutes - [Configuration](#configuration) - Customize your setup - [Troubleshooting](#troubleshooting) - Common issues diff --git a/docs/configuration.md b/docs/configuration.md index ca0c9e5..f455879 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,21 +47,25 @@ Complete reference for configuring the OpenHax Codex Plugin. Controls computational effort for reasoning. **GPT-5 Values:** + - `minimal` - Fastest, least reasoning - `low` - Light reasoning - `medium` - Balanced (default) - `high` - Deep reasoning **GPT-5-Codex Values:** + - `low` - Fastest for code - `medium` - Balanced (default) - `high` - Maximum code quality **Notes**: + - `minimal` auto-converts to `low` for gpt-5-codex (API limitation) - `gpt-5-codex-mini*` only supports `medium` or `high`; lower settings are clamped to `medium` **Example:** + ```json { "options": { @@ -75,10 +79,12 @@ Controls computational effort for reasoning. Controls reasoning summary verbosity. **Values:** + - `auto` - Automatically adapts (default) - `detailed` - Verbose summaries **Example:** + ```json { "options": { @@ -92,14 +98,17 @@ Controls reasoning summary verbosity. Controls output length. **GPT-5 Values:** + - `low` - Concise - `medium` - Balanced (default) - `high` - Verbose **GPT-5-Codex:** + - `medium` only (API limitation) **Example:** + ```json { "options": { @@ -117,6 +126,7 @@ Array of additional response fields to include. **Why needed**: Enables multi-turn conversations with `store: false` (stateless mode) **Example:** + ```json { "options": { @@ -132,6 +142,7 @@ Controls server-side conversation persistence. **⚠️ Required**: `false` (for AI SDK 2.0.50+ compatibility) **Values:** + - `false` - Stateless mode (required for Codex API) - `true` - Server-side storage (not supported by Codex API) @@ -139,6 +150,7 @@ Controls server-side conversation persistence. AI SDK 2.0.50+ automatically uses `item_reference` items when `store: true`. The Codex API requires stateless operation (`store: false`), where references cannot be resolved. **Example:** + ```json { "options": { @@ -241,6 +253,7 @@ Different settings for different models: - **`id` field**: DEPRECATED - not used by OpenAI provider **Example Usage:** + ```bash # Use the config key in CLI opencode run "task" --model=openai/my-custom-id @@ -323,6 +336,7 @@ Different agents use different models: Global config has defaults, project overrides for specific work: **~/.config/opencode/opencode.json** (global): + ```json { "plugin": ["@openhax/codex"], @@ -338,6 +352,7 @@ Global config has defaults, project overrides for specific work: ``` **my-project/.opencode.json** (project): + ```json { "provider": { @@ -362,15 +377,14 @@ Advanced plugin settings in `~/.opencode/openhax-codex-config.json`: ```json { "codexMode": true, - "enableCodexCompaction": true, - "autoCompactTokenLimit": 12000, - "autoCompactMinMessages": 8 + "enablePromptCaching": true } ``` ### Log file management Control local request/rolling log growth: + - `CODEX_LOG_MAX_BYTES` (default: 5_242_880) - rotate when the rolling log exceeds this many bytes. - `CODEX_LOG_MAX_FILES` (default: 5) - number of rotated log files to retain (plus the active log). - `CODEX_LOG_QUEUE_MAX` (default: 1000) - maximum buffered log entries before oldest entries are dropped. @@ -378,39 +392,24 @@ Control local request/rolling log growth: ### CODEX_MODE **What it does:** + - `true` (default): Uses Codex-OpenCode bridge prompt (Task tool & MCP aware) - `false`: Uses legacy tool remap message - Bridge prompt content is synced with the latest Codex CLI release (ETag-cached) **When to disable:** + - Compatibility issues with OpenCode updates - Testing different prompt styles - Debugging tool call issues **Override with environment variable:** + ```bash CODEX_MODE=0 opencode run "task" # Temporarily disable CODEX_MODE=1 opencode run "task" # Temporarily enable ``` -### enableCodexCompaction - -Controls whether the plugin exposes Codex-style compaction commands. - -- `true` (default): `/codex-compact` is available and auto-compaction heuristics may run if enabled. -- `false`: Compaction commands are ignored and OpenCode's own prompts pass through untouched. - -Disable only if you prefer OpenCode's host-side compaction or while debugging prompt differences. - -### autoCompactTokenLimit / autoCompactMinMessages - -Configures the optional auto-compaction heuristic. - -- `autoCompactTokenLimit`: Approximate token budget (based on character count ÷ 4). When unset, auto-compaction never triggers. -- `autoCompactMinMessages`: Minimum number of conversation turns before auto-compaction is considered (default `8`). - -When the limit is reached, the plugin injects a Codex summary, stores it for future turns, and replies: “Auto compaction triggered… Review the summary then resend your last instruction.” - ### Prompt caching - When OpenCode provides a `prompt_cache_key` (its session identifier), the plugin forwards it directly to Codex. @@ -429,12 +428,14 @@ When the limit is reached, the plugin injects a Codex summary, stores it for fut ## Configuration Files **Provided Examples:** + - [config/full-opencode.json](../config/full-opencode.json) - Complete with 11 variants (adds Codex Mini presets) - [config/minimal-opencode.json](../config/minimal-opencode.json) - Minimal setup > **Why choose the full config?** OpenCode's auto-compaction and usage widgets rely on the per-model `limit` metadata present only in `full-opencode.json`. Use the minimal config only if you don't need those UI features. **Your Configs:** + - `~/.config/opencode/opencode.json` - Global config - `/.opencode.json` - Project-specific config - `~/.opencode/openhax-codex-config.json` - Plugin config @@ -458,6 +459,7 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/your-model-name ``` Look for: + ``` [openhax/codex] Model config lookup: "your-model-name" → normalized to "gpt-5-codex" for API { hasModelSpecificConfig: true, @@ -512,6 +514,7 @@ Old verbose names still work: ``` **Benefits:** + - Cleaner: `--model=openai/gpt-5-codex-low` - Matches Codex CLI preset names - No redundant `id` field @@ -608,9 +611,11 @@ Old verbose names still work: **Cause**: Config key doesn't match model name in command **Fix**: Use exact config key: + ```json { "models": { "my-model": { ... } } } ``` + ```bash opencode run "test" --model=openai/my-model # Must match exactly ``` @@ -630,9 +635,11 @@ Look for `hasModelSpecificConfig: true` in debug output. **Cause**: Model normalizes before lookup **Example Problem:** + ```json { "models": { "gpt-5-codex": { "options": { ... } } } } ``` + ```bash --model=openai/gpt-5-codex-low # Normalizes to "gpt-5-codex" before lookup ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 82f81ea..370487d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -207,6 +207,7 @@ Add this to `~/.config/opencode/opencode.json`: ``` **What you get:** + - ✅ GPT-5 Codex (Low/Medium/High reasoning) - ✅ GPT-5 (Minimal/Low/Medium/High reasoning) - ✅ gpt-5-mini, gpt-5-nano (lightweight variants) @@ -290,6 +291,7 @@ opencode ``` **When to update:** + - New features released - Bug fixes available - Security updates @@ -313,6 +315,7 @@ For plugin development or testing unreleased changes: **Note**: Must point to `dist/` folder (built output), not root. **Build the plugin:** + ```bash cd codex npm install @@ -355,11 +358,13 @@ ls ~/.opencode/logs/codex-plugin/ **Prompt caching is enabled by default** to minimize your costs. ### What This Means + - Your conversation context is preserved across turns - Token usage is significantly reduced for multi-turn conversations - Lower overall costs compared to stateless operation ### Managing Caching + Create `~/.opencode/openhax-codex-config.json`: ```json @@ -369,27 +374,12 @@ Create `~/.opencode/openhax-codex-config.json`: ``` **Settings:** + - `true` (default): Optimize for cost savings - `false`: Fresh context each turn (higher costs) **⚠️ Warning**: Disabling caching will dramatically increase token usage and costs. -### Compaction Controls - -To mirror the Codex CLI `/compact` command, add the following to `~/.opencode/openhax-codex-config.json`: - -```json -{ - "enableCodexCompaction": true, - "autoCompactTokenLimit": 12000, - "autoCompactMinMessages": 8 -} -``` - -- `enableCodexCompaction` toggles both the `/codex-compact` manual command and Codex-side history rewrites. -- Set `autoCompactTokenLimit` to have the plugin run compaction automatically once the conversation grows beyond the specified budget. -- Users receive the Codex summary (with the standard `SUMMARY_PREFIX`) and can immediately resend their paused instruction; subsequent turns are rebuilt from the stored summary instead of the entire backlog. - --- ## Next Steps diff --git a/lib/config.ts b/lib/config.ts index e4db55a..c387139 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -12,8 +12,6 @@ const CONFIG_PATH = getOpenCodePath("openhax-codex-config.json"); const DEFAULT_CONFIG: PluginConfig = { codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }; let cachedPluginConfig: PluginConfig | undefined; diff --git a/lib/request/codex-fetcher.ts b/lib/request/codex-fetcher.ts index 725864e..424fa8f 100644 --- a/lib/request/codex-fetcher.ts +++ b/lib/request/codex-fetcher.ts @@ -1,7 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { maybeHandleCodexCommand } from "../commands/codex-metrics.js"; -import { finalizeCompactionResponse } from "../compaction/compaction-executor.js"; import { LOG_STAGES } from "../constants.js"; import { logRequest } from "../logger.js"; import { recordSessionResponseFromHandledResponse } from "../session/response-recorder.js"; @@ -93,16 +92,7 @@ export function createCodexFetcher(deps: CodexFetcherDeps) { return await handleErrorResponse(response); } - let handledResponse = await handleSuccessResponse(response, hasTools); - - if (transformation?.compactionDecision) { - handledResponse = await finalizeCompactionResponse({ - response: handledResponse, - decision: transformation.compactionDecision, - sessionManager, - sessionContext, - }); - } + const handledResponse = await handleSuccessResponse(response, hasTools); await recordSessionResponseFromHandledResponse({ sessionManager, diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index aa1839f..f904197 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -5,8 +5,6 @@ import type { Auth, OpencodeClient } from "@opencode-ai/sdk"; import { refreshAccessToken } from "../auth/auth.js"; -import { detectCompactionCommand } from "../compaction/codex-compaction.js"; -import type { CompactionDecision } from "../compaction/compaction-executor.js"; import { ERROR_MESSAGES, HTTP_STATUS, @@ -18,7 +16,6 @@ import { import { logError, logRequest } from "../logger.js"; import type { SessionManager } from "../session/session-manager.js"; import type { PluginConfig, RequestBody, SessionContext, UserConfig } from "../types.js"; -import { cloneInputItems } from "../utils/clone.js"; import { transformRequestBody } from "./request-transformer.js"; import { convertSseToJson, ensureContentType } from "./response-handler.js"; @@ -99,14 +96,6 @@ export function rewriteUrlForCodex(url: string): string { return url.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES); } -function buildCompactionSettings(pluginConfig?: PluginConfig) { - return { - enabled: pluginConfig?.enableCodexCompaction !== false, - autoLimitTokens: pluginConfig?.autoCompactTokenLimit, - autoMinMessages: pluginConfig?.autoCompactMinMessages ?? 8, - }; -} - function applyPromptCacheKey(body: RequestBody, sessionContext?: SessionContext): RequestBody { const promptCacheKey = sessionContext?.state?.promptCacheKey; if (!promptCacheKey) return body; @@ -119,17 +108,6 @@ function applyPromptCacheKey(body: RequestBody, sessionContext?: SessionContext) return { ...(body as any), prompt_cache_key: promptCacheKey } as RequestBody; } -function applyCompactionHistory( - body: RequestBody, - sessionManager: SessionManager | undefined, - sessionContext: SessionContext | undefined, - settings: { enabled: boolean }, - manualCommand: string | null, -): void { - if (!settings.enabled || manualCommand) return; - sessionManager?.applyCompactedHistory?.(body, sessionContext); -} - /** * Transforms request body and logs the transformation * @param init - Request init options @@ -146,13 +124,12 @@ export async function transformRequestForCodex( userConfig: UserConfig, codexMode = true, sessionManager?: SessionManager, - pluginConfig?: PluginConfig, + _pluginConfig?: PluginConfig, ): Promise< | { body: RequestBody; updatedInit: RequestInit; sessionContext?: SessionContext; - compactionDecision?: CompactionDecision; } | undefined > { @@ -161,19 +138,9 @@ export async function transformRequestForCodex( try { const body = JSON.parse(init.body as string) as RequestBody; const originalModel = body.model; - const originalInput = cloneInputItems(body.input ?? []); - const compactionSettings = buildCompactionSettings(pluginConfig); - const manualCommand = compactionSettings.enabled ? detectCompactionCommand(originalInput) : null; const sessionContext = sessionManager?.getContext(body); const bodyWithCacheKey = applyPromptCacheKey(body, sessionContext); - applyCompactionHistory( - bodyWithCacheKey, - sessionManager, - sessionContext, - compactionSettings, - manualCommand, - ); logRequest(LOG_STAGES.BEFORE_TRANSFORM, { url, @@ -193,11 +160,6 @@ export async function transformRequestForCodex( codexMode, { preserveIds: sessionContext?.preserveIds, - compaction: { - settings: compactionSettings, - commandText: manualCommand, - originalInput, - }, }, sessionContext, ); @@ -226,7 +188,6 @@ export async function transformRequestForCodex( body: transformResult.body, updatedInit, sessionContext: appliedContext, - compactionDecision: transformResult.compactionDecision, }; } catch (e) { logError(ERROR_MESSAGES.REQUEST_PARSE_ERROR, { diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index b75e01f..d6c9f4e 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -1,5 +1,4 @@ /* eslint-disable no-param-reassign */ -import type { CompactionDecision } from "../compaction/compaction-executor.js"; import { logDebug, logWarn } from "../logger.js"; import type { RequestBody, SessionContext, UserConfig } from "../types.js"; import { @@ -8,7 +7,6 @@ import { filterInput, filterOpenCodeSystemPrompts, } from "./input-filters.js"; -import { applyCompactionIfNeeded, type CompactionOptions } from "./compaction-helpers.js"; import { getModelConfig, getReasoningConfig, normalizeModel } from "./model-config.js"; import { ensurePromptCacheKey, logCacheKeyDecision } from "./prompt-cache.js"; import { normalizeToolsForCodexBody } from "./tooling.js"; @@ -23,16 +21,13 @@ export { export { getModelConfig, getReasoningConfig, normalizeModel } from "./model-config.js"; export interface TransformRequestOptions { - /** Preserve IDs only when conversation transforms run; may be a no-op when compaction skips them. */ + /** Preserve IDs when prompt caching requires it. */ preserveIds?: boolean; - /** Compaction settings and original input context used when building compaction prompts. */ - compaction?: CompactionOptions; } export interface TransformResult { /** Mutated request body (same instance passed into transformRequestBody). */ body: RequestBody; - compactionDecision?: CompactionDecision; } async function transformInputForCodex( @@ -41,9 +36,8 @@ async function transformInputForCodex( preserveIds: boolean, hasNormalizedTools: boolean, sessionContext?: SessionContext, - skipConversationTransforms = false, ): Promise { - if (!body.input || !Array.isArray(body.input) || skipConversationTransforms) { + if (!body.input || !Array.isArray(body.input)) { return; } @@ -94,12 +88,6 @@ export async function transformRequestBody( const normalizedModel = normalizeModel(body.model); const preserveIds = options.preserveIds ?? false; - const compactionDecision = applyCompactionIfNeeded( - body, - options.compaction && { ...options.compaction, preserveIds }, - ); - const skipConversationTransforms = Boolean(compactionDecision); - const lookupModel = originalModel || normalizedModel; const modelConfig = getModelConfig(lookupModel, userConfig); @@ -117,16 +105,9 @@ export async function transformRequestBody( const isNewSession = sessionContext?.isNew ?? true; logCacheKeyDecision(cacheKeyResult, isNewSession); - const hasNormalizedTools = normalizeToolsForCodexBody(body, skipConversationTransforms); + const hasNormalizedTools = normalizeToolsForCodexBody(body, false); - await transformInputForCodex( - body, - codexMode, - preserveIds, - hasNormalizedTools, - sessionContext, - skipConversationTransforms, - ); + await transformInputForCodex(body, codexMode, preserveIds, hasNormalizedTools, sessionContext); const reasoningConfig = getReasoningConfig(originalModel, modelConfig); body.reasoning = { @@ -144,5 +125,5 @@ export async function transformRequestBody( body.max_output_tokens = undefined; body.max_completion_tokens = undefined; - return { body, compactionDecision }; + return { body }; } diff --git a/lib/types.ts b/lib/types.ts index 0c4439d..5dff8f7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -16,22 +16,6 @@ export interface PluginConfig { * @default true */ enablePromptCaching?: boolean; - - /** - * Enable Codex-style compaction commands inside the plugin - * @default true - */ - enableCodexCompaction?: boolean; - - /** - * Optional auto-compaction token limit (approximate tokens) - */ - autoCompactTokenLimit?: number; - - /** - * Minimum number of conversation messages before auto-compacting - */ - autoCompactMinMessages?: number; } /** diff --git a/spec/codex-compaction.md b/spec/codex-compaction.md deleted file mode 100644 index 877918d..0000000 --- a/spec/codex-compaction.md +++ /dev/null @@ -1,73 +0,0 @@ -# Codex-Style Compaction Implementation - -## References -- Issue: #5 "Feature: Codex-style conversation compaction and auto-compaction in plugin" -- Existing PRs: none as of 2025-11-16 (confirmed via `gh pr list`) -- Upstream reference: `openai/codex` (`codex-rs/core/src/compact.rs` and `templates/compact/*.md`) - -## Current State -- `lib/request/request-transformer.ts:530-660` only strips OpenCode auto-compaction prompts; no plugin-owned summary flow exists. -- `lib/commands/codex-metrics.ts` handles `/codex-metrics` and `/codex-inspect` by intercepting the latest user text and returning static SSE responses; no compaction command handler is present. -- `SessionManager` stores prompt-cache metadata but lacks any notion of compaction history or pending auto-compaction state. -- Docs/config files mention OpenCode auto-compaction but have no plugin config for enabling/disabling Codex-specific compaction. - -## Requirements -1. Manual compaction command: - - Recognize `/codex-compact`, `/compact`, and `codex-compact` user inputs (case-insensitive) before the request hits Codex. - - Replace the outgoing request body with a Codex-style compaction prompt constructed from the filtered conversation history. - - Return the Codex-generated summary to the host as the full response; no downstream tools should run. -2. Auto-compaction heuristics: - - Add plugin config for `enableCodexCompaction` (manual command toggle, default `true`), `autoCompactTokenLimit` (unset/disabled by default), and `autoCompactMinMessages` (default `8`). - - When the limit is configured, approximate the token count for the in-flight `input` after filtering; if above limit and turn count ≥ min messages, automatically run a compaction request before sending the user prompt. - - Auto compaction should respond with the generated summary and include a note telling the user their request was paused until compaction finished (matching Codex CLI expectations). -3. Shared compaction utilities: - - Port over the Codex CLI `SUMMARIZATION_PROMPT` and `SUMMARY_PREFIX` templates. - - Provide helper(s) for serializing conversation history into a text blob, truncating old turns to avoid extremely long compaction prompts, and building the synthetic request body used for compaction. - - Expose consistent metadata (e.g., `{ command: "codex-compact", auto: boolean, truncatedTurns: number }`) on command responses so frontends/tests can assert behavior. -4. Tests: - - Extend `test/request-transformer.test.ts` to cover manual command rewriting, auto-compaction triggering when thresholds are exceeded, and no-op behavior when thresholds aren't met. - - Add unit coverage for compaction helpers (new file under `test/` mirroring the module name) validating serialization, truncation, and prompt construction. -5. Documentation: - - Update `docs/configuration.md` and `README.md` with the new plugin config knobs and CLI usage instructions for `/codex-compact`. - - Mention auto-compaction defaults (disabled) and how to enable them via `~/.opencode/openhax-codex-config.json`. - -## Implementation Plan -### Phase 1 – Config & Prompt Assets -- Update `lib/types.ts` (`PluginConfig`) to add compaction-related fields plus any helper interfaces. -- Create `lib/prompts/codex-compaction.ts` exporting `CODEX_COMPACTION_PROMPT` + `CODEX_SUMMARY_PREFIX` (copied from upstream templates) and metadata about estimated tokens. -- Extend `lib/config.ts` defaults (new keys) and ensure `loadPluginConfig()` surfaces compaction settings. -- Document the options in `docs/configuration.md` and reference them from `README.md`. - -### Phase 2 – Compaction Utilities -- Add `lib/compaction/codex-compaction.ts` with helpers: - - `normalizeCommandTrigger()` (shared with command detection) and `isCompactionCommand(text)`. - - `serializeConversation(items: InputItem[], options)` returning truncated transcript text + stats about dropped turns. - - `buildCompactionInput(conversationText: string)` returning the synthetic `InputItem[]` (developer prompt + user transcript) used to call Codex. - - `approximateTokenCount(items)` used for auto-compaction heuristic. -- Include pure functions for formatting the assistant response when compaction completes (e.g., prefixing with `SUMMARY_PREFIX`). -- Write focused unit tests for this module in `test/codex-compaction.test.ts`. - -### Phase 3 – Request Transformation & Command Handling -- Update `transformRequestBody()` to accept compaction config (plumbed from `transformRequestForCodex` → `createCodexFetcher`). -- Inside `transformRequestBody`, before final logging: - - Detect manual compaction command via helpers; when hit, strip the command message, serialize the rest, and rewrite `body.input` to the compaction prompt. Clear `tools`, set `metadata.codex_compaction = { mode: "command", truncatedTurns }`, and short-circuit auto-compaction heuristics. - - If no manual command, evaluate auto-compaction threshold; if triggered, generate the same compaction prompt as above, set metadata to `{ mode: "auto", reason: "token_limit" }`, and stash the original user text (we'll prompt the user to resend after compaction message). -- Return a flag along with the transformed body so downstream knows whether this request is a compaction run. (E.g., set `body.metadata.codex_compaction.active = true`.) -- Update `maybeHandleCodexCommand()` (and call site) to an async function so `/codex-metrics` continues to work while compaction is handled upstream. (Manual compaction detection will now live in the transformer rather than command handler, so metrics module only needs minimal changes.) - -### Phase 4 – Response Handling & Messaging -- Introduce `lib/request/compaction-response.ts` (or extend existing logic) to detect when a handled response corresponds to a compaction request (based on metadata set earlier). -- For manual command requests: leave the Codex-generated summary untouched so it streams back to the host as the immediate response. -- For auto-compaction-triggered requests: prepend a short assistant note ("Auto compaction finished; please continue") before the summary, so users understand why their prior question wasn't processed. -- Update `session/response-recorder` if needed to avoid caching compaction runs as normal prompt-cache turns (optional but mention in spec if not planned). - -### Phase 5 – Documentation & Validation -- Explain `/codex-compact` usage and auto-compaction behavior in README + docs. -- Add configuration snippet example to `docs/configuration.md` and CLI usage example to `README.md`. -- Run `npm test` (Vitest) to confirm the new suites pass. - -## Definition of Done -- `/codex-compact` command rewrites the outgoing request into a Codex-style compaction prompt and streams the summary back to the user. -- Optional auto-compaction runs when thresholds are exceeded and informs the user via assistant response. -- Compaction helper tests verify serialization/truncation rules; `request-transformer` tests assert rewriting + metadata behavior. -- Documentation reflects the new commands and configuration switches. diff --git a/spec/compaction-heuristics-22.md b/spec/compaction-heuristics-22.md deleted file mode 100644 index 638a46e..0000000 --- a/spec/compaction-heuristics-22.md +++ /dev/null @@ -1,51 +0,0 @@ -# Issue 22 – Compaction heuristics metadata flag - -**Issue**: https://github.com/open-hax/codex/issues/22 (follow-up to PR #20 review comment r2532755818) - -## Context & Current Behavior - -- Compaction prompt sanitization lives in `lib/request/input-filters.ts:72-165` (`filterOpenCodeSystemPrompts`). It relies on regex heuristics over content to strip OpenCode auto-compaction summary-file instructions. -- Core filtering pipeline in `lib/request/request-transformer.ts:38-75` runs `filterInput` **before** `filterOpenCodeSystemPrompts`; `filterInput` currently strips `metadata` when `preserveIds` is false, so any upstream metadata markers are lost before heuristic detection. -- Compaction prompts produced by this plugin are built in `lib/compaction/codex-compaction.ts:88-99` via `buildCompactionPromptItems`, but no metadata flags are attached to identify them as OpenCode compaction artifacts. -- Tests for the filtering behavior live in `test/request-transformer.test.ts:539-618` and currently cover regex-only heuristics (no metadata awareness). - -## Problem - -Heuristic-only detection risks false positives/negatives. Review feedback requested an explicit metadata flag on OpenCode compaction prompts (e.g., `metadata.source === "opencode-compaction"`) and to prefer that flag over regex checks, falling back to heuristics when metadata is absent. - -## Solution Strategy - -### Phase 1: Metadata flag plumbing - -- Tag plugin-generated compaction prompt items (developer + user) with a clear metadata flag, e.g., `metadata: { source: "opencode-compaction" }` or boolean `opencodeCompaction`. Ensure the flag survives filtering. -- Adjust the filtering pipeline to preserve metadata long enough for detection (e.g., allow metadata passthrough pre-sanitization or re-order detection vs. stripping) while still removing other metadata before sending to Codex backend unless IDs are preserved. - -### Phase 2: Metadata-aware filtering - -- Update `filterOpenCodeSystemPrompts` to first check metadata flags for compaction/system prompts and sanitize/remove based on that before running regex heuristics. Heuristics remain as fallback when metadata is missing. -- Ensure system prompt detection (`isOpenCodeSystemPrompt`) remains unchanged. - -### Phase 3: Tests - -- Expand `test/request-transformer.test.ts` to cover: - - Metadata-tagged compaction prompts being sanitized/removed (preferred path). - - Fallback to heuristics when metadata flag is absent. - - Metadata preserved just long enough for detection but not leaked when `preserveIds` is false. - -## Definition of Done / Requirements - -- [x] Incoming OpenCode compaction prompts marked with metadata are detected and sanitized/removed without relying on text heuristics. -- [x] Heuristic detection remains functional when metadata is absent. -- [x] Metadata needed for detection is not stripped before filtering; final output still omits metadata unless explicitly preserved. -- [x] Tests updated/added to cover metadata flag path and fallback behavior. - -## Files to Modify - -- `lib/compaction/codex-compaction.ts` – attach metadata flag to compaction prompt items built by the plugin. -- `lib/request/input-filters.ts` – prefer metadata-aware detection and keep heuristics as fallback. -- `lib/request/request-transformer.ts` – ensure metadata survives into filter stage (ordering/options tweak) but is removed thereafter when appropriate. -- `test/request-transformer.test.ts` – add coverage for metadata-flagged compaction prompts and fallback behavior. - -## Change Log - -- 2025-11-20: Implemented metadata flag detection/preservation pipeline, tagged compaction prompt builders, added metadata-focused tests, and ran `npm test -- request-transformer.test.ts`. diff --git a/spec/remove-plugin-compaction.md b/spec/remove-plugin-compaction.md new file mode 100644 index 0000000..dfc7ff9 --- /dev/null +++ b/spec/remove-plugin-compaction.md @@ -0,0 +1,30 @@ +# Remove plugin compaction + +## Scope + +Remove Codex plugin-specific compaction (manual + auto) so compaction is left to OpenCode or other layers. + +## Code refs (entry points) + +- lib/request/fetch-helpers.ts: compaction settings, detectCompactionCommand, pass compaction options to transform, track compactionDecision. +- lib/request/request-transformer.ts: applyCompactionIfNeeded, skip transforms when compactionDecision present. +- lib/request/compaction-helpers.ts: builds compaction prompt and decision logic. +- lib/compaction/codex-compaction.ts and lib/prompts/codex-compaction.ts: prompt content and helpers (detect command, approximate tokens, build summary). +- lib/compaction/compaction-executor.ts: rewrites responses and stores summaries. +- lib/session/session-manager.ts: applyCompactionSummary/applyCompactedHistory state injections. +- lib/request/input-filters.ts: compaction heuristics and metadata flags. +- lib/types.ts: plugin config fields for compaction. +- lib/request/codex-fetcher.ts: finalizeCompactionResponse usage. +- Tests: compaction-executor.test.ts, codex-compaction.test.ts, compaction-helpers.test.ts, codex-fetcher.test.ts, fetch-helpers.test.ts (compaction section), request-transformer.test.ts (compaction metadata), session-manager.test.ts (compaction state), docs README/configuration/getting-started. + +## Definition of done + +- Plugin no longer performs or triggers compaction (manual/auto) in request/response flow. +- Plugin config no longer exposes compaction knobs, docs updated accordingly. +- Tests updated/removed to reflect lack of plugin compaction. + +## Requirements + +- Preserve prompt caching/session behavior unrelated to compaction. +- Avoid breaking tool/transform flow; codex bridge still applied. +- Keep code ASCII and minimal surgical changes. diff --git a/test/codex-compaction.test.ts b/test/codex-compaction.test.ts deleted file mode 100644 index 7f26163..0000000 --- a/test/codex-compaction.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - approximateTokenCount, - buildCompactionPromptItems, - collectSystemMessages, - createSummaryMessage, - detectCompactionCommand, - extractTailAfterSummary, - serializeConversation, -} from "../lib/compaction/codex-compaction.js"; -import type { InputItem } from "../lib/types.js"; - -describe("codex compaction helpers", () => { - it("detects slash commands in latest user message", () => { - const input: InputItem[] = [ - { type: "message", role: "user", content: "hello" }, - { type: "message", role: "assistant", content: "response" }, - { type: "message", role: "user", content: "/codex-compact please" }, - ]; - - expect(detectCompactionCommand(input)).toBe("codex-compact please"); - }); - - it("serializes conversation while truncating older turns", () => { - const turns: InputItem[] = Array.from({ length: 5 }, (_, index) => ({ - type: "message", - role: index % 2 === 0 ? "user" : "assistant", - content: `message-${index + 1}`, - })); - - const { transcript, totalTurns, droppedTurns } = serializeConversation(turns, 40); - expect(totalTurns).toBe(5); - expect(droppedTurns).toBeGreaterThan(0); - expect(transcript).toContain("## User"); - expect(transcript).toMatch(/message-4/); - }); - - it("builds compaction prompt with developer + user messages", () => { - const items = buildCompactionPromptItems("Example transcript"); - expect(items).toHaveLength(2); - expect(items[0].role).toBe("developer"); - expect(items[1].role).toBe("user"); - }); - - it("collects developer/system instructions for reuse", () => { - const items: InputItem[] = [ - { type: "message", role: "system", content: "sys" }, - { type: "message", role: "developer", content: "dev" }, - { type: "message", role: "user", content: "user" }, - ]; - const collected = collectSystemMessages(items); - expect(collected).toHaveLength(2); - expect(collected[0].content).toBe("sys"); - }); - - it("wraps summary with prefix when needed", () => { - const summary = createSummaryMessage("Short summary"); - expect(typeof summary.content).toBe("string"); - expect(summary.content as string).toContain("Another language model"); - }); - - it("estimates token count via text length heuristic", () => { - const items: InputItem[] = [{ type: "message", role: "user", content: "a".repeat(200) }]; - expect(approximateTokenCount(items)).toBeGreaterThan(40); - }); - - it("returns zero tokens when there is no content", () => { - expect(approximateTokenCount(undefined)).toBe(0); - expect(approximateTokenCount([])).toBe(0); - }); - - it("ignores user messages without compaction commands", () => { - const input: InputItem[] = [ - { type: "message", role: "user", content: "just chatting" }, - { type: "message", role: "assistant", content: "reply" }, - ]; - expect(detectCompactionCommand(input)).toBeNull(); - }); - - it("extracts tail after the latest user summary message", () => { - const items: InputItem[] = [ - { type: "message", role: "user", content: "review summary" }, - { type: "message", role: "assistant", content: "analysis" }, - { type: "message", role: "user", content: "follow-up" }, - ]; - const tail = extractTailAfterSummary(items); - expect(tail).toHaveLength(1); - expect(tail[0].role).toBe("user"); - }); - - it("returns empty tail when no user summary exists", () => { - const input: InputItem[] = [{ type: "message", role: "assistant", content: "analysis" }]; - expect(extractTailAfterSummary(input)).toEqual([]); - }); -}); diff --git a/test/codex-fetcher.test.ts b/test/codex-fetcher.test.ts index 1aa6881..0658d2a 100644 --- a/test/codex-fetcher.test.ts +++ b/test/codex-fetcher.test.ts @@ -17,7 +17,6 @@ const maybeHandleCodexCommandMock = vi.hoisted(() => ); const logRequestMock = vi.hoisted(() => vi.fn()); const recordSessionResponseMock = vi.hoisted(() => vi.fn()); -const finalizeCompactionResponseMock = vi.hoisted(() => vi.fn()); vi.mock("../lib/request/fetch-helpers.js", () => ({ __esModule: true, @@ -46,11 +45,6 @@ vi.mock("../lib/session/response-recorder.js", () => ({ recordSessionResponseFromHandledResponse: recordSessionResponseMock, })); -vi.mock("../lib/compaction/compaction-executor.js", () => ({ - __esModule: true, - finalizeCompactionResponse: finalizeCompactionResponseMock, -})); - describe("createCodexFetcher", () => { const sessionManager = { recordResponse: vi.fn(), @@ -93,8 +87,6 @@ describe("createCodexFetcher", () => { pluginConfig: { codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }, }); @@ -122,8 +114,6 @@ describe("createCodexFetcher", () => { { codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }, ); expect(maybeHandleCodexCommandMock).toHaveBeenCalled(); @@ -288,41 +278,6 @@ describe("createCodexFetcher", () => { }); }); - it("handles compaction decision when present", async () => { - const mockDecision = { type: "compact" as const, reason: "test" }; - const compactedResponse = new Response("compacted", { status: 200 }); - transformRequestForCodexMock.mockResolvedValue({ - body: { model: "gpt-5" }, - sessionContext: { sessionId: "s-3", enabled: true }, - compactionDecision: mockDecision, - }); - handleSuccessResponseMock.mockResolvedValue(new Response("payload", { status: 200 })); - finalizeCompactionResponseMock.mockResolvedValue(compactedResponse); - - const fetcher = createCodexFetcher(baseDeps()); - const result = await fetcher("https://api.openai.com", {}); - - // Verify finalizeCompactionResponse was called with correct parameters - expect(finalizeCompactionResponseMock).toHaveBeenCalledWith({ - response: expect.any(Response), - decision: mockDecision, - sessionManager, - sessionContext: { sessionId: "s-3", enabled: true }, - }); - - // Verify recordSessionResponseFromHandledResponse was called with compacted response - expect(recordSessionResponseMock).toHaveBeenCalledWith({ - sessionManager, - sessionContext: { sessionId: "s-3", enabled: true }, - handledResponse: compactedResponse, - }); - - // Verify fetcher returns the compacted response - expect(result).toBe(compactedResponse); - expect(result.status).toBe(200); - expect(await result.text()).toBe("compacted"); - }); - it("uses empty tokens when auth type is not oauth", async () => { transformRequestForCodexMock.mockResolvedValue({ body: { model: "gpt-5" }, diff --git a/test/compaction-executor.test.ts b/test/compaction-executor.test.ts deleted file mode 100644 index d270a13..0000000 --- a/test/compaction-executor.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - type CompactionDecision, - finalizeCompactionResponse, -} from "../lib/compaction/compaction-executor.js"; -import { CODEX_SUMMARY_PREFIX } from "../lib/prompts/codex-compaction.js"; -import type { SessionManager } from "../lib/session/session-manager.js"; -import type { SessionContext } from "../lib/types.js"; - -describe("Compaction executor", () => { - it("rewrites auto compaction output, metadata, and persists summary", async () => { - const initialPayload = { - output: [ - { - role: "assistant", - content: [ - { - type: "output_text", - text: "Original reasoning", - }, - ], - }, - ], - metadata: { version: 1 }, - }; - const decision: CompactionDecision = { - mode: "auto", - reason: "token limit", - preservedSystem: [{ type: "message", role: "system", content: "system instructions" }], - serialization: { - transcript: "transcript", - totalTurns: 3, - droppedTurns: 1, - }, - }; - const response = new Response(JSON.stringify(initialPayload), { - status: 202, - statusText: "Accepted", - headers: { "x-custom": "header" }, - }); - const sessionManager = { applyCompactionSummary: vi.fn() } as unknown as SessionManager; - const sessionContext: SessionContext = { - sessionId: "session-abc", - enabled: true, - preserveIds: true, - isNew: false, - state: { - id: "session-abc", - promptCacheKey: "prompt-abc", - store: false, - lastInput: [], - lastPrefixHash: null, - lastUpdated: Date.now(), - }, - }; - - const finalized = await finalizeCompactionResponse({ - response, - decision, - sessionManager, - sessionContext, - }); - - expect(finalized.status).toBe(202); - expect(finalized.statusText).toBe("Accepted"); - expect(finalized.headers.get("x-custom")).toBe("header"); - - const body = JSON.parse(await finalized.text()); - expect(body.output[0].content[0].text).toContain("Auto compaction triggered (token limit)"); - expect(body.output[0].content[0].text).toContain(CODEX_SUMMARY_PREFIX); - expect(body.metadata.codex_compaction).toMatchObject({ - mode: "auto", - reason: "token limit", - total_turns: 3, - dropped_turns: 1, - }); - expect(sessionManager.applyCompactionSummary).toHaveBeenCalledWith(sessionContext, { - baseSystem: decision.preservedSystem, - summary: expect.stringContaining(CODEX_SUMMARY_PREFIX), - }); - }); - - it("gracefully handles payloads without assistant output", async () => { - const emptyPayload = { output: [], metadata: {} }; - const decision: CompactionDecision = { - mode: "command", - preservedSystem: [], - serialization: { transcript: "", totalTurns: 0, droppedTurns: 0 }, - }; - const response = new Response(JSON.stringify(emptyPayload), { - status: 200, - }); - - const finalized = await finalizeCompactionResponse({ response, decision }); - const body = JSON.parse(await finalized.text()); - - expect(finalized.status).toBe(200); - expect(body.output).toEqual([]); - expect(body.metadata.codex_compaction).toMatchObject({ - mode: "command", - dropped_turns: 0, - total_turns: 0, - }); - }); - - it("does not add auto note when compaction is command-based", async () => { - const payload = { - output: [ - { - role: "assistant", - content: [{ type: "output_text", text: "Previous might" }], - }, - ], - metadata: {}, - }; - const decision: CompactionDecision = { - mode: "command", - preservedSystem: [], - serialization: { transcript: "", totalTurns: 1, droppedTurns: 0 }, - }; - const response = new Response(JSON.stringify(payload), { - status: 200, - }); - - const finalized = await finalizeCompactionResponse({ response, decision }); - const body = JSON.parse(await finalized.text()); - - expect(body.output[0].content[0].text).toContain(CODEX_SUMMARY_PREFIX); - expect(body.output[0].content[0].text).not.toContain("Auto compaction triggered"); - expect(body.metadata.codex_compaction.mode).toBe("command"); - expect(body.metadata.codex_compaction.reason).toBeUndefined(); - }); -}); diff --git a/test/compaction-helpers.test.ts b/test/compaction-helpers.test.ts deleted file mode 100644 index fdfc2da..0000000 --- a/test/compaction-helpers.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { applyCompactionIfNeeded } from "../lib/request/compaction-helpers.js"; -import type { InputItem, RequestBody } from "../lib/types.js"; - -describe("compaction helpers", () => { - it("drops only the last user command and keeps trailing items", () => { - const originalInput: InputItem[] = [ - { type: "message", role: "assistant", content: "previous response" }, - { type: "message", role: "user", content: "/codex-compact please" }, - { type: "message", role: "assistant", content: "trailing assistant" }, - ]; - const body: RequestBody = { model: "gpt-5", input: [...originalInput] }; - - const decision = applyCompactionIfNeeded(body, { - settings: { enabled: true }, - commandText: "codex-compact please", - originalInput, - }); - - expect(decision?.mode).toBe("command"); - expect(decision?.serialization.transcript).toContain("previous response"); - expect(decision?.serialization.transcript).toContain("trailing assistant"); - expect(decision?.serialization.transcript).not.toContain("codex-compact please"); - - // Verify RequestBody mutations - expect(body.input).not.toEqual(originalInput); - expect(body.input?.some((item) => item.content === "/codex-compact please")).toBe(false); - expect((body as any).tools).toBeUndefined(); - expect((body as any).tool_choice).toBeUndefined(); - expect((body as any).parallel_tool_calls).toBeUndefined(); - }); - - it("returns original items when no user message exists", () => { - const originalInput: InputItem[] = [ - { - type: "message", - role: "assistant", - content: "system-only follow-up", - }, - ]; - const body: RequestBody = { model: "gpt-5", input: [...originalInput] }; - - const decision = applyCompactionIfNeeded(body, { - settings: { enabled: true }, - commandText: null, // No command, so no compaction should occur - originalInput, - }); - - // No compaction should occur when there's no command text - expect(decision).toBeUndefined(); - // Verify RequestBody mutations - body should remain unchanged - expect(body.input).toBeDefined(); - expect(body.input).toEqual(originalInput); - expect((body as any).tools).toBeUndefined(); - expect((body as any).tool_choice).toBeUndefined(); - expect((body as any).parallel_tool_calls).toBeUndefined(); - }); -}); diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 7a5809b..05b0333 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -296,7 +296,6 @@ describe("Fetch Helpers Module", () => { applyRequest: vi.fn().mockReturnValue(appliedContext), }; - const pluginConfig = { enableCodexCompaction: false }; const result = await transformRequestForCodex( { body: JSON.stringify(body) }, "https://chatgpt.com/backend-api/codex/responses", @@ -304,19 +303,9 @@ describe("Fetch Helpers Module", () => { { global: {}, models: {} }, true, sessionManager as never, - pluginConfig as any, ); expect(transformRequestBodyMock).toHaveBeenCalledTimes(1); - const [_passedBody, _passedInstructions, _passedUserConfig, _passedCodexMode, optionsArg] = - transformRequestBodyMock.mock.calls[0]; - - expect(Array.isArray(optionsArg?.compaction?.originalInput)).toBe(true); - expect(optionsArg?.compaction?.originalInput).not.toBe(body.input); - - body.input[0].content = "mutated"; - expect(optionsArg?.compaction?.originalInput?.[0].content).toBe("hello"); - expect(result?.body).toEqual(transformed); // Note: updatedInit.body is serialized once from transformResult.body and won't reflect later mutations to transformResult.body expect(result?.updatedInit.body).toBe(JSON.stringify(transformed)); @@ -355,7 +344,6 @@ describe("Fetch Helpers Module", () => { { global: {}, models: {} }, true, sessionManager as never, - { enableCodexCompaction: false } as any, ); const [passedBody] = transformRequestBodyMock.mock.calls[0]; @@ -396,7 +384,6 @@ describe("Fetch Helpers Module", () => { { global: {}, models: {} }, true, sessionManager as never, - { enableCodexCompaction: false } as any, ); const [passedBody] = transformRequestBodyMock.mock.calls[0]; diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 2ffa5e1..34c289e 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -52,8 +52,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }); expect(mockExistsSync).toHaveBeenCalledWith( path.join(os.homedir(), ".opencode", "openhax-codex-config.json"), @@ -69,8 +67,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: false, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }); }); @@ -83,8 +79,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }); }); @@ -98,8 +92,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }); expect(logWarnSpy).toHaveBeenCalled(); logWarnSpy.mockRestore(); @@ -117,8 +109,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, }); expect(logWarnSpy).toHaveBeenCalled(); logWarnSpy.mockRestore(); From e0f2509f32a68712ffde7fe1fa9b4cf4176a1526 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 21 Nov 2025 21:45:27 +0000 Subject: [PATCH 06/17] Added config override assertions to logger tests. Co-authored-by: riatzukiza --- package-lock.json | 4 ++-- test/logger.test.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 158f8cf..36843d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openhax/codex", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openhax/codex", - "version": "0.4.0", + "version": "0.4.1", "license": "GPL-3.0-only", "dependencies": { "@openauthjs/openauth": "^0.4.3", diff --git a/test/logger.test.ts b/test/logger.test.ts index 8bc63f3..9929631 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -67,8 +67,12 @@ afterEach(() => { describe("logger", () => { it("isLoggingEnabled reflects env state", async () => { process.env.ENABLE_PLUGIN_REQUEST_LOGGING = "1"; - const { isLoggingEnabled } = await import("../lib/logger.js"); + const { isLoggingEnabled, configureLogger } = await import("../lib/logger.js"); expect(isLoggingEnabled()).toBe(true); + + // Test that config overrides are reflected + configureLogger({ pluginConfig: { logging: { enableRequestLogging: false } } }); + expect(isLoggingEnabled()).toBe(false); }); it("logRequest writes stage file and rolling log when enabled", async () => { @@ -110,10 +114,15 @@ describe("logger", () => { it("config overrides env-enabled request logging when disabled in file", async () => { process.env.ENABLE_PLUGIN_REQUEST_LOGGING = "1"; fsMocks.existsSync.mockReturnValue(true); - const { configureLogger, logRequest, flushRollingLogsForTest } = await import("../lib/logger.js"); + const { configureLogger, logRequest, flushRollingLogsForTest, isLoggingEnabled } = await import( + "../lib/logger.js" + ); configureLogger({ pluginConfig: { logging: { enableRequestLogging: false } } }); + // Verify isLoggingEnabled reflects the config override + expect(isLoggingEnabled()).toBe(false); + logRequest("stage-one", { foo: "bar" }); await flushRollingLogsForTest(); From ee1f888fb369ed0f04cbcd14ada0587009fa24d9 Mon Sep 17 00:00:00 2001 From: Err Date: Fri, 21 Nov 2025 15:50:05 -0600 Subject: [PATCH 07/17] chore: release v0.4.2 (PR #68) (#72) Co-authored-by: github-actions[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 722132d..fed2321 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openhax/codex", - "version": "0.4.1", + "version": "0.4.2", "description": "OpenHax Codex OAuth plugin for Opencode — bring your ChatGPT Plus/Pro subscription instead of API credits", "main": "./dist/index.js", "types": "./dist/index.d.ts", From ed41bffeec22a0f9cb5e895dbc41918fadd8759b Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 15:50:41 -0600 Subject: [PATCH 08/17] Add prefix mismatch diagnostics and tests --- lib/session/session-manager.ts | 118 +++++++++++++++++++++++++++++++++ spec/prefix-diagnostics.md | 29 ++++++++ test/session-manager.test.ts | 86 +++++++++++++++++++++++- 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 spec/prefix-diagnostics.md diff --git a/lib/session/session-manager.ts b/lib/session/session-manager.ts index 69ace53..c1c641a 100644 --- a/lib/session/session-manager.ts +++ b/lib/session/session-manager.ts @@ -77,6 +77,118 @@ function sanitizeCacheKey(candidate: string): string { return trimmed; } +function isSystemLike(item: InputItem | undefined): boolean { + if (!item || typeof item.role !== "string") { + return false; + } + const role = item.role.toLowerCase(); + return role === "system" || role === "developer"; +} + +function isToolMessage(item: InputItem | undefined): boolean { + if (!item) return false; + const role = typeof item.role === "string" ? item.role.toLowerCase() : ""; + const type = typeof item.type === "string" ? item.type.toLowerCase() : ""; + const hasToolCall = + "tool_call_id" in (item as Record) || "tool_calls" in (item as Record); + return ( + role === "tool" || + type === "tool" || + type === "tool_call" || + type === "tool_result" || + type === "function" || + hasToolCall + ); +} + +function fingerprintInputItem(item: InputItem | undefined): string | undefined { + if (!item) return undefined; + try { + return createHash("sha1").update(JSON.stringify(item)).digest("hex").slice(0, 8); + } catch { + return undefined; + } +} + +function summarizeRoles(items: InputItem[]): string[] { + const roles = new Set(); + for (const item of items) { + if (typeof item.role === "string" && item.role.trim()) { + roles.add(item.role); + } + } + return Array.from(roles); +} + +function findSuffixReuseStart(previous: InputItem[], current: InputItem[]): number | null { + if (previous.length === 0 || current.length === 0 || current.length > previous.length) { + return null; + } + const start = previous.length - current.length; + for (let index = 0; index < current.length; index += 1) { + const prevItem = previous[start + index]; + if (JSON.stringify(prevItem) !== JSON.stringify(current[index])) { + return null; + } + } + return start; +} + +type PrefixChangeCause = "system_prompt_changed" | "history_pruned" | "unknown"; + +type PrefixChangeAnalysis = { + cause: PrefixChangeCause; + details: Record; +}; + +function analyzePrefixChange( + previous: InputItem[], + current: InputItem[], + sharedPrefixLength: number, +): PrefixChangeAnalysis { + const firstPrevious = previous[sharedPrefixLength]; + const firstIncoming = current[sharedPrefixLength]; + const suffixReuseStart = findSuffixReuseStart(previous, current); + const removedSegment = + suffixReuseStart !== null && suffixReuseStart > 0 ? previous.slice(0, suffixReuseStart) : []; + const removedToolCount = removedSegment.filter((item) => isToolMessage(item)).length; + + if (suffixReuseStart !== null && removedSegment.length > 0) { + return { + cause: "history_pruned", + details: { + mismatchIndex: sharedPrefixLength, + suffixReuseStart, + removedCount: removedSegment.length, + removedToolCount, + removedRoles: summarizeRoles(removedSegment), + }, + }; + } + + if (isSystemLike(firstPrevious) || isSystemLike(firstIncoming)) { + return { + cause: "system_prompt_changed", + details: { + mismatchIndex: sharedPrefixLength, + previousRole: firstPrevious?.role, + incomingRole: firstIncoming?.role, + previousFingerprint: fingerprintInputItem(firstPrevious), + incomingFingerprint: fingerprintInputItem(firstIncoming), + }, + }; + } + + return { + cause: "unknown", + details: { + mismatchIndex: sharedPrefixLength, + previousRole: firstPrevious?.role, + incomingRole: firstIncoming?.role, + }, + }; +} + function buildPrefixForkIds( baseSessionId: string, basePromptCacheKey: string, @@ -294,11 +406,15 @@ export class SessionManager { const hasFullPrefixMatch = sharedPrefixLength === state.lastInput.length; if (!hasFullPrefixMatch) { + const prefixAnalysis = analyzePrefixChange(state.lastInput, input, sharedPrefixLength); if (sharedPrefixLength === 0) { logWarn("SessionManager: prefix mismatch detected, regenerating cache key", { sessionId: state.id, + sharedPrefixLength, previousItems: state.lastInput.length, incomingItems: input.length, + prefixCause: prefixAnalysis.cause, + ...prefixAnalysis.details, }); const refreshed = this.resetSessionInternal(state.id, true); if (!refreshed) { @@ -351,6 +467,8 @@ export class SessionManager { sharedPrefixLength, previousItems: state.lastInput.length, incomingItems: input.length, + prefixCause: prefixAnalysis.cause, + ...prefixAnalysis.details, }); // eslint-disable-next-line no-param-reassign body.prompt_cache_key = forkPromptCacheKey; diff --git a/spec/prefix-diagnostics.md b/spec/prefix-diagnostics.md new file mode 100644 index 0000000..aaa0ad1 --- /dev/null +++ b/spec/prefix-diagnostics.md @@ -0,0 +1,29 @@ +# Prefix diagnostics for cache warnings + +## Context + +- Repeated warnings `SessionManager: prefix mismatch detected ...` do not explain whether a cache prefix changed because the system prompt shifted, OpenCode pruned earlier tool results, or some other drift. +- Prefix comparison happens after request transformation (post bridge/system filtering), so the mismatch data needs to come from `SessionManager.applyRequest()`. + +## Code links + +- `lib/session/session-manager.ts:93-360` — session context retrieval and prefix comparison; warnings emitted on mismatches/forks. +- `lib/request/request-transformer.ts:86-147` — transformation pipeline that feeds `SessionManager`, including cache key derivation. +- `lib/request/input-filters.ts:18-272` — system prompt filtering + bridge injection that can affect the cached prefix shape. + +## Existing issues / PRs + +- Spec `spec/session-prefix-mismatch.md` tracks earlier prefix churn; no open PRs found tied to cause attribution in logs. + +## Definition of done + +- Warning logs for prefix mismatches classify whether the divergence stems from a system/developer prompt change, OpenCode context pruning (detected loss of leading history/tool results), or an unknown/other cause. +- Added metadata makes it easy to pinpoint removed segments (counts/types/hashes) without changing session behavior. +- Tests cover the new classification helper and ensure existing session reuse/fork behaviors remain intact. + +## Requirements + +- Add a lightweight diff classifier in `SessionManager.applyRequest` that inspects the previous vs incoming inputs when a mismatch occurs. +- Include classification + concise evidence in `logWarn` payloads for both regenerate and fork paths. +- Treat OpenCode pruning heuristically (e.g., incoming input matches a suffix of the prior turn and dropped items include tool/result roles). +- Keep hashing/clone behavior unchanged; do not alter prompt cache key computation or transformation order. diff --git a/test/session-manager.test.ts b/test/session-manager.test.ts index 7d17701..fa6a820 100644 --- a/test/session-manager.test.ts +++ b/test/session-manager.test.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { SESSION_CONFIG } from "../lib/constants.js"; import { SessionManager } from "../lib/session/session-manager.js"; +import * as logger from "../lib/logger.js"; import type { InputItem, RequestBody, SessionContext } from "../lib/types.js"; interface BodyOptions { @@ -109,6 +110,89 @@ describe("SessionManager", () => { expect(branchContext.state.promptCacheKey).not.toBe(context.state.promptCacheKey); }); + it("logs system prompt changes when regenerating cache key", () => { + const warnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {}); + const manager = new SessionManager({ enabled: true }); + const baseBody: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-system-change" }, + input: [ + { type: "message", role: "system", content: "initial system" }, + { type: "message", role: "user", content: "hello" }, + ], + }; + + let context = manager.getContext(baseBody) as SessionContext; + context = manager.applyRequest(baseBody, context) as SessionContext; + + const changedBody: RequestBody = { + ...baseBody, + input: [ + { type: "message", role: "system", content: "updated system prompt" }, + { type: "message", role: "user", content: "hello" }, + ], + }; + + const nextContext = manager.getContext(changedBody) as SessionContext; + manager.applyRequest(changedBody, nextContext); + + const warnCall = warnSpy.mock.calls.find( + ([message]) => typeof message === "string" && message.includes("prefix mismatch"), + ); + + expect(warnCall?.[1]).toMatchObject({ + prefixCause: "system_prompt_changed", + previousRole: "system", + incomingRole: "system", + }); + + warnSpy.mockRestore(); + }); + + it("logs history pruning when earlier tool results are removed", () => { + const warnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {}); + const manager = new SessionManager({ enabled: true }); + const fullBody: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-history-prune" }, + input: [ + { type: "message", role: "system", content: "sys" }, + { type: "message", role: "user", content: "step 1" }, + { + type: "message", + role: "assistant", + content: "tool call", + tool_calls: [{ id: "call-1" }], + }, + { type: "message", role: "tool", content: "tool output", tool_call_id: "call-1" }, + { type: "message", role: "user", content: "follow up" }, + ], + }; + + let context = manager.getContext(fullBody) as SessionContext; + context = manager.applyRequest(fullBody, context) as SessionContext; + + const prunedBody: RequestBody = { + ...fullBody, + input: fullBody.input ? fullBody.input.slice(4) : [], + }; + + const prunedContext = manager.getContext(prunedBody) as SessionContext; + manager.applyRequest(prunedBody, prunedContext); + + const warnCall = warnSpy.mock.calls.find( + ([message]) => typeof message === "string" && message.includes("prefix mismatch"), + ); + + expect(warnCall?.[1]).toMatchObject({ + prefixCause: "history_pruned", + removedCount: 4, + }); + expect((warnCall?.[1] as Record)?.removedRoles).toContain("tool"); + + warnSpy.mockRestore(); + }); + it("forks session when prefix matches partially and reuses compaction state", () => { const manager = new SessionManager({ enabled: true }); const baseBody = createBody("conv-prefix-fork", 3); From a455bd13c8355af16f37169ec247717a003d4362 Mon Sep 17 00:00:00 2001 From: Err Date: Fri, 21 Nov 2025 16:23:05 -0600 Subject: [PATCH 09/17] chore: release v0.4.3 (PR #71) (#74) Co-authored-by: github-actions[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fed2321..5e2b714 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openhax/codex", - "version": "0.4.2", + "version": "0.4.3", "description": "OpenHax Codex OAuth plugin for Opencode — bring your ChatGPT Plus/Pro subscription instead of API credits", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 0552640592fe9517f8aa50d7b20474c2b82d7201 Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 17:38:58 -0600 Subject: [PATCH 10/17] chore: drop compaction and tune logging --- CHANGELOG.md | 37 ++++- README.md | 24 +++- docs/code-cleanup-summary.md | 14 +- docs/code-quality-analysis-report.md | 113 ++++++++------- docs/configuration.md | 18 ++- docs/getting-started.md | 2 +- docs/notes/2025.11.19.18.38.24.md | 56 +------- lib/compaction/codex-compaction.ts | 155 -------------------- lib/compaction/compaction-executor.ts | 99 ------------- lib/config.ts | 1 + lib/logger.ts | 11 +- lib/prompts/codex-compaction.ts | 11 -- lib/request/compaction-helpers.ts | 107 -------------- lib/request/input-filters.ts | 83 ----------- lib/session/session-manager.ts | 42 +----- lib/types.ts | 4 +- spec/auto-compaction-summary.md | 32 ----- spec/cache-analysis.md | 20 ++- spec/complexity-reduction.md | 2 +- spec/issue-triage-2025-11-20.md | 23 --- spec/log-warnings-default-file-only.md | 39 ++++++ spec/merge-conflict-resolution.md | 18 ++- spec/open-issues-triage.md | 187 ------------------------- spec/plugin-log-settings-doc.md | 24 ++++ spec/pr-2-conflict-analysis.md | 24 ---- spec/pr-20-review.md | 28 ---- spec/pr-29-review-analysis.md | 7 +- spec/pr-commit-2025-11-21.md | 32 +++++ spec/readme-cleanup.md | 2 +- spec/remove-plugin-compaction.md | 30 ---- spec/request-transformer-refactor.md | 10 +- spec/review-pr-20-plan.md | 28 ---- spec/review-v0.3.5-fixes.md | 2 - spec/session-prefix-mismatch.md | 7 +- test/logger.test.ts | 25 +++- test/plugin-config.test.ts | 14 +- test/prompts-codex.test.ts | 16 +-- test/request-transformer.test.ts | 42 +----- test/session-manager.test.ts | 107 -------------- 39 files changed, 345 insertions(+), 1151 deletions(-) delete mode 100644 lib/compaction/codex-compaction.ts delete mode 100644 lib/compaction/compaction-executor.ts delete mode 100644 lib/prompts/codex-compaction.ts delete mode 100644 lib/request/compaction-helpers.ts delete mode 100644 spec/auto-compaction-summary.md delete mode 100644 spec/issue-triage-2025-11-20.md create mode 100644 spec/log-warnings-default-file-only.md delete mode 100644 spec/open-issues-triage.md create mode 100644 spec/plugin-log-settings-doc.md delete mode 100644 spec/pr-2-conflict-analysis.md delete mode 100644 spec/pr-20-review.md create mode 100644 spec/pr-commit-2025-11-21.md delete mode 100644 spec/remove-plugin-compaction.md delete mode 100644 spec/review-pr-20-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcfa80..fcb39ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,89 +3,124 @@ All notable changes to this project are documented here. Dates use the ISO format (YYYY-MM-DD). ## [3.3.0] - 2025-11-19 + ### Added + - Codex Max support that mirrors the Codex CLI: normalization for every `gpt-5.1-codex-max` alias, `reasoningEffort: "xhigh"`, and unit tests covering both the transformer and request body integration path. - Documentation and configuration updates calling out Codex Max as the flagship preset, plus refreshed samples showing how to opt into the Extra High reasoning mode. ### Changed + - Sample configs (`full` + `minimal`), README tables, AGENTS.md, and the diagnostics script now prefer `gpt-5.1-codex-max`, keeping plugin defaults aligned with Codex CLI behaviour. ### Fixed + - Requests that specify `reasoningEffort: "xhigh"` for non-supported models are now automatically downgraded to `high`, preventing API errors when Codex Max isn't selected. ## [3.2.0] - 2025-11-13 + ### Added + - GPT-5.1 family integration: normalization for `gpt-5.1`/`gpt-5.1-codex`/`gpt-5.1-codex-mini`, expanded reasoning heuristics (including `reasoningEffort: "none"`), and preservation of the native `shell`/`apply_patch` tools emitted by Codex CLI. - Updated configuration, diagnostics script, and docs to showcase the 5.1 lineup (low/medium/high plus `none`) while keeping GPT-5 presets available for backwards compatibility. ### Changed + - Default fallback model now targets `gpt-5.1`, and Codex Mini requests always use the new `gpt-5.1-codex-mini` slug to stay in sync with the latest Codex release. ### Fixed + - Prevented invalid reasoning combinations by clamping unsupported `none`/`minimal` requests on Codex models and ensuring parallel tool-call behavior matches both GPT-5 and GPT-5.1 Codex variants. ## [3.1.0] - 2025-11-11 + ### Added + - Codex Mini support end-to-end: normalization to the `codex-mini-latest` slug, proper reasoning defaults, and two new presets (`gpt-5-codex-mini-medium` / `gpt-5-codex-mini-high`). - Documentation & configuration updates describing the Codex Mini tier (200k input / 100k output tokens) plus refreshed totals (11 presets, 160+ unit tests). ### Fixed + - Prevented Codex Mini from inheriting the lightweight (`minimal`) reasoning profile used by `gpt-5-mini`/`nano`, ensuring the API always receives supported effort levels. ## [3.0.0] - 2025-11-04 + ### Added + - Codex-style usage-limit messaging that mirrors the 5-hour and weekly windows reported by the Codex CLI. -- Documentation guidance noting that OpenCode's context auto-compaction and usage sidebar require the canonical `config/full-opencode.json`. +- Documentation guidance noting that OpenCode's usage sidebar requires the canonical `config/full-opencode.json`. ### Changed + - Prompt caching now relies solely on the host-supplied `prompt_cache_key`; conversation/session headers are forwarded only when OpenCode provides one. - CODEX_MODE bridge prompt refreshed to the newest Codex CLI release so tool awareness stays in sync. ### Fixed + - Clarified README, docs, and configuration references so the canonical config matches shipped behaviour. - Pinned `hono` (4.10.4) and `vite` (7.1.12) to resolve upstream security advisories. ## [2.1.2] - 2025-10-12 + ### Added + - Comprehensive compliance documentation (ToS guidance, security, privacy) and a full user/developer doc set. ### Fixed + - Per-model configuration lookup, stateless multi-turn conversations, case-insensitive model normalization, and GitHub instruction caching. ## [2.1.1] - 2025-10-04 + ### Fixed + - README cache-clearing snippet now runs in a subshell from the home directory to avoid path issues while removing cached plugin files. ## [2.1.0] - 2025-10-04 + ### Added + - Enhanced CODEX_MODE bridge prompt with Task tool and MCP awareness plus ETag-backed verification of OpenCode system prompts. ### Changed + - Request transformation made async to support prompt verification caching; AGENTS.md renamed to provide cross-agent guidance. ## [2.0.0] - 2025-10-03 + ### Added + - Full TypeScript rewrite with strict typing, 123 automated tests, and nine pre-configured model variants matching the Codex CLI. - CODEX_MODE introduced (enabled by default) with a lightweight bridge prompt and configurability via config file or `CODEX_MODE` env var. ### Changed + - Library reorganized into semantic folders (auth, prompts, request, etc.) and OAuth flow polished with the new success page. ## [1.0.3] - 2025-10-02 + ### Changed + - Major internal refactor splitting the runtime into focused modules (logger, request/response handlers) and removing legacy debug output. ## [1.0.2] - 2025-10-02 + ### Added + - ETag-based GitHub caching for Codex instructions and release-tag tracking for more stable prompt updates. ### Fixed + - Default model fallback, text verbosity initialization, and standardized error logging prefixes. ## [1.0.1] - 2025-10-01 + ### Added + - README clarifications: opencode auto-installs plugins, config locations, and streamlined quick-start instructions. ## [1.0.0] - 2025-10-01 + ### Added + - Initial production release with ChatGPT Plus/Pro OAuth support, tool remapping, auto-updating Codex instructions, and zero runtime dependencies. diff --git a/README.md b/README.md index 77b3c78..4e8615b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,14 @@ Set these in `~/.opencode/openhax-codex-config.json` (applies to all models): - `codexMode` (default `true`): enable the Codex ↔ OpenCode bridge prompt and tool remapping - `enablePromptCaching` (default `true`): keep a stable `prompt_cache_key` so Codex can reuse cached prompts +- `logging` (optional): override log defaults and related env vars (`ENABLE_PLUGIN_REQUEST_LOGGING`, `DEBUG_CODEX_PLUGIN`, `CODEX_LOG_MAX_BYTES`, `CODEX_LOG_MAX_FILES`, `CODEX_LOG_QUEUE_MAX`, `CODEX_SHOW_WARNING_TOASTS`, `CODEX_LOG_WARNINGS_TO_CONSOLE`). Fields: + - `enableRequestLogging`: force request log persistence even without `ENABLE_PLUGIN_REQUEST_LOGGING=1` + - `debug`: force debug logging regardless of env + - `showWarningToasts`: show warning-level toasts in the OpenCode UI + - `logWarningsToConsole`: mirror warnings to console when toasts are off + - `logMaxBytes` (default `5_242_880` bytes): rotate rolling log after this size + - `logMaxFiles` (default `5`): rotated log files to retain (plus the active log) + - `logQueueMax` (default `1000`): max buffered log entries before oldest entries drop Example: @@ -59,9 +67,12 @@ Example: { "codexMode": true, "enablePromptCaching": true, - "enableCodexCompaction": true, - "autoCompactTokenLimit": 120000, - "autoCompactMinMessages": 8 + "logging": { + "enableRequestLogging": true, + "logMaxBytes": 5242880, + "logMaxFiles": 5, + "logQueueMax": 1000 + } } ``` @@ -93,6 +104,13 @@ Example: - **Reduces token consumption** by reusing cached prompts - **Lowers costs** significantly for multi-turn conversations +### Reducing Cache Churn (keep `prompt_cache_key` stable) + +- Why caches reset: OpenCode rebuilds the system/developer prompt every turn; the env block includes today’s date and a ripgrep tree of your workspace, so daily rollovers or file tree changes alter the prefix and trigger a new cache key. +- Keep the tree stable: ensure noisy/ephemeral dirs are ignored (e.g. `dist/`, `build/`, `.next/`, `coverage/`, `.cache/`, `logs/`, `tmp/`, `.turbo/`, `.vite/`, `.stryker-tmp/`, `artifacts/`, and similar). Put transient outputs under an ignored directory or `/tmp`. +- Don’t thrash the workspace mid-session: large checkouts, mass file generation, or moving directories will change the ripgrep listing and force a cache miss. +- Model/provider switches also change the system prompt (different base prompt), so avoid swapping models in the middle of a session if you want to reuse cache. + ### Managing Caching #### Recommended: Full Configuration (Codex CLI Experience) diff --git a/docs/code-cleanup-summary.md b/docs/code-cleanup-summary.md index aa0c630..b114209 100644 --- a/docs/code-cleanup-summary.md +++ b/docs/code-cleanup-summary.md @@ -13,7 +13,7 @@ 2. **Created InputItemUtils** - `lib/utils/input-item-utils.ts` - Centralized text extraction logic used in multiple modules - Added utility functions for role checking, filtering, and formatting - - Eliminates duplication in `request-transformer.ts`, `session-manager.ts`, and `codex-compaction.ts` + - Eliminates duplication in `request-transformer.ts` and `session-manager.ts` - Functions: `extractTextFromItem()`, `hasTextContent()`, `formatRole()`, `formatEntry()`, `isSystemMessage()`, `isUserMessage()`, `isAssistantMessage()`, `filterByRole()`, `getLastUserMessage()`, `countConversationTurns()` 3. **Refactored Large Functions** @@ -53,12 +53,14 @@ ## Code Quality Improvements ### Before Refactoring + - **Code Duplication**: 3+ duplicate clone implementations - **Large Functions**: `transformRequestBody()` 1130 lines with high complexity - **Magic Numbers**: Scattered TTL values and limits throughout codebase - **No Complexity Enforcement**: No cognitive complexity limits ### After Refactoring + - **Eliminated Duplication**: Single source of truth for cloning and text extraction - **Reduced Complexity**: Large function now uses focused utility functions - **Centralized Configuration**: All magic numbers in constants with descriptive names @@ -67,30 +69,34 @@ ## Files Modified ### New Files Created + - `lib/utils/clone.ts` - Shared cloning utilities - `lib/utils/input-item-utils.ts` - InputItem processing utilities ### Files Updated + - `lib/constants.ts` - Added centralized configuration constants - `biome.json` - Enhanced linting rules for complexity - `lib/request/request-transformer.ts` - Updated to use shared utilities - `lib/session/session-manager.ts` - Updated to use shared utilities and constants -- `lib/compaction/codex-compaction.ts` - Updated to use shared utilities - `test/session-manager.test.ts` - Updated imports for new constants ## Impact ### Maintainability + - **Easier to modify** cloning behavior in one place - **Clearer separation of concerns** with focused utility functions - **Better discoverability** of common operations ### Performance + - **Optimized cloning** with `structuredClone` when available - **Reduced memory allocation** through shared utilities - **Consistent error handling** patterns ### Code Quality + - **Enforced complexity limits** to prevent future issues - **Standardized patterns** across all modules - **Improved type safety** with centralized utilities @@ -98,13 +104,15 @@ ## Next Steps The codebase now has: + - **B+ code quality rating** (improved from existing baseline) - **Zero critical code smells** - **Comprehensive test coverage** maintained - **Automated quality gates** in place Future development will benefit from: + - Shared utilities reducing duplication - Complexity limits preventing excessive nesting - Centralized configuration for easy maintenance -- Consistent patterns across all modules \ No newline at end of file +- Consistent patterns across all modules diff --git a/docs/code-quality-analysis-report.md b/docs/code-quality-analysis-report.md index 338c801..eb84161 100644 --- a/docs/code-quality-analysis-report.md +++ b/docs/code-quality-analysis-report.md @@ -7,6 +7,7 @@ This report analyzes the OpenHax Codex plugin codebase for code duplication, cod ## Key Findings ### ✅ Strengths + - **Excellent modular architecture** with clear separation of concerns - **Comprehensive test coverage** with 123 tests across all modules - **Strong type safety** with TypeScript interfaces and proper typing @@ -14,6 +15,7 @@ This report analyzes the OpenHax Codex plugin codebase for code duplication, cod - **Effective caching strategies** with proper TTL and invalidation ### ⚠️ Areas for Improvement + - **Large functions** that could be broken down - **Code duplication** in utility functions - **Complex conditional logic** in some areas @@ -24,6 +26,7 @@ This report analyzes the OpenHax Codex plugin codebase for code duplication, cod ## 1. Code Duplication Issues ### 1.1 Clone/Deep Copy Patterns + **Severity: Medium** Multiple modules implement similar deep cloning logic: @@ -31,31 +34,23 @@ Multiple modules implement similar deep cloning logic: ```typescript // In request-transformer.ts:29 function cloneInputItem>(item: T): T { - return JSON.parse(JSON.stringify(item)) as T; + return JSON.parse(JSON.stringify(item)) as T; } // In session-manager.ts:24 function getCloneFn(): CloneFn { - const globalClone = (globalThis as unknown as { structuredClone?: CloneFn }).structuredClone; - if (typeof globalClone === "function") { - return globalClone; - } - return (value: T) => JSON.parse(JSON.stringify(value)) as T; + const globalClone = (globalThis as unknown as { structuredClone?: CloneFn }).structuredClone; + if (typeof globalClone === "function") { + return globalClone; + } + return (value: T) => JSON.parse(JSON.stringify(value)) as T; } - -// In codex-compaction.ts:7 -const cloneValue = (() => { - const globalClone = (globalThis as { structuredClone?: (value: T) => T }).structuredClone; - if (typeof globalClone === "function") { - return (value: T) => globalClone(value); - } - return (value: T) => JSON.parse(JSON.stringify(value)) as T; -})(); ``` **Recommendation:** Create a shared utility `lib/utils/clone.ts` with a single implementation. ### 1.2 Hash Computation Duplication + **Severity: Low** Similar hash computation patterns appear in multiple places: @@ -63,21 +58,20 @@ Similar hash computation patterns appear in multiple places: ```typescript // request-transformer.ts:49 function computePayloadHash(item: InputItem): string { - const canonical = stableStringify(item); - return createHash("sha1").update(canonical).digest("hex"); + const canonical = stableStringify(item); + return createHash("sha1").update(canonical).digest("hex"); } // session-manager.ts:41 function computeHash(items: InputItem[]): string { - return createHash("sha1") - .update(JSON.stringify(items)) - .digest("hex"); + return createHash("sha1").update(JSON.stringify(items)).digest("hex"); } ``` **Recommendation:** Consolidate into a shared hashing utility. ### 1.3 Text Extraction Patterns + **Severity: Low** Multiple modules extract text from InputItem objects with similar logic: @@ -85,16 +79,16 @@ Multiple modules extract text from InputItem objects with similar logic: ```typescript // request-transformer.ts:510 const getContentText = (item: InputItem): string => { - if (typeof item.content === "string") { - return item.content; - } - if (Array.isArray(item.content)) { - return item.content - .filter((c) => c.type === "input_text" && c.text) - .map((c) => c.text) - .join("\n"); - } - return ""; + if (typeof item.content === "string") { + return item.content; + } + if (Array.isArray(item.content)) { + return item.content + .filter((c) => c.type === "input_text" && c.text) + .map((c) => c.text) + .join("\n"); + } + return ""; }; ``` @@ -105,10 +99,12 @@ const getContentText = (item: InputItem): string => { ### 2.1 Large Functions #### `transformRequestBody()` - 1130 lines + **File:** `lib/request/request-transformer.ts:973` **Severity: High** This function handles too many responsibilities: + - Model normalization - Configuration merging - Input filtering @@ -117,29 +113,34 @@ This function handles too many responsibilities: - Cache key management **Recommendation:** Break into smaller functions: + - `normalizeModelAndConfig()` - `processInputArray()` - `handleToolConfiguration()` - `managePromptInjection()` #### `getCodexInstructions()` - 218 lines + **File:** `lib/prompts/codex.ts:44` **Severity: Medium** Complex caching logic with multiple fallback paths. **Recommendation:** Extract: + - `loadFromFileCache()` - `fetchFromGitHub()` - `handleFetchFailure()` #### `handleErrorResponse()` - 77 lines + **File:** `lib/request/fetch-helpers.ts:252` **Severity: Medium** Complex error parsing and enrichment logic. **Recommendation:** Extract: + - `parseRateLimitHeaders()` - `enrichUsageLimitError()` - `createErrorResponse()` @@ -147,29 +148,31 @@ Complex error parsing and enrichment logic. ### 2.2 Complex Conditional Logic #### Model Normalization Logic + **File:** `lib/request/request-transformer.ts:314-347` ```typescript export function normalizeModel(model: string | undefined): string { - const fallback = "gpt-5.1"; - if (!model) return fallback; + const fallback = "gpt-5.1"; + if (!model) return fallback; - const lowered = model.toLowerCase(); - const sanitized = lowered.replace(/\./g, "-").replace(/[\s_\/]+/g, "-"); + const lowered = model.toLowerCase(); + const sanitized = lowered.replace(/\./g, "-").replace(/[\s_\/]+/g, "-"); - const contains = (needle: string) => sanitized.includes(needle); - const hasGpt51 = contains("gpt-5-1") || sanitized.includes("gpt51"); + const contains = (needle: string) => sanitized.includes(needle); + const hasGpt51 = contains("gpt-5-1") || sanitized.includes("gpt51"); - if (contains("gpt-5-1-codex-mini") || (hasGpt51 && contains("codex-mini"))) { - return "gpt-5.1-codex-mini"; - } - // ... many more conditions + if (contains("gpt-5-1-codex-mini") || (hasGpt51 && contains("codex-mini"))) { + return "gpt-5.1-codex-mini"; + } + // ... many more conditions } ``` **Recommendation:** Use a configuration-driven approach with model mapping tables. #### Reasoning Configuration Logic + **File:** `lib/request/request-transformer.ts:379-437` Complex nested conditionals for determining reasoning parameters. @@ -200,16 +203,19 @@ export const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes ## 3. Anti-Patterns ### 3.1 God Object Configuration + **File:** `lib/types.ts` - 240 lines The `RequestBody` interface has too many optional properties, making it difficult to understand the required structure. **Recommendation:** Split into focused interfaces: + - `BaseRequestBody` - `ToolRequest` extends BaseRequestBody - `StreamingRequest` extends BaseRequestBody ### 3.2 Stringly-Typed Configuration + **Severity: Medium** Multiple places use string constants for configuration: @@ -217,28 +223,29 @@ Multiple places use string constants for configuration: ```typescript // constants.ts:70 export const AUTH_LABELS = { - OAUTH: "ChatGPT Plus/Pro (Codex Subscription)", - API_KEY: "Manually enter API Key", - INSTRUCTIONS: "A browser window should open. Complete login to finish.", + OAUTH: "ChatGPT Plus/Pro (Codex Subscription)", + API_KEY: "Manually enter API Key", + INSTRUCTIONS: "A browser window should open. Complete login to finish.", } as const; ``` **Recommendation:** Use enums or const assertions for better type safety. ### 3.3 Inconsistent Error Handling + **Severity: Low** Some functions throw exceptions while others return error objects: ```typescript // auth.ts:128 - returns TokenResult -export async function refreshAccessToken(refreshToken: string): Promise +export async function refreshAccessToken(refreshToken: string): Promise; // server.ts:64 - resolves with error object resolve({ - port: 1455, - close: () => server.close(), - waitForCode: async () => null, + port: 1455, + close: () => server.close(), + waitForCode: async () => null, }); ``` @@ -247,17 +254,19 @@ resolve({ ## 4. Test Code Issues ### 4.1 Repetitive Test Setup + **Severity: Low** Many test files have similar setup patterns: ```typescript -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; ``` **Recommendation:** Create test utilities in `test/helpers/`. ### 4.2 Mock Duplication + **Severity: Low** Similar mock patterns across multiple test files. @@ -267,6 +276,7 @@ Similar mock patterns across multiple test files. ## 5. Performance Concerns ### 5.1 Inefficient String Operations + **Severity: Low** Multiple JSON.stringify/deepClone operations in hot paths. @@ -274,6 +284,7 @@ Multiple JSON.stringify/deepClone operations in hot paths. **Recommendation:** Use structuredClone where available, cache results. ### 5.2 Redundant Network Requests + **Severity: Low** Potential for multiple cache warming calls. @@ -283,6 +294,7 @@ Potential for multiple cache warming calls. ## 6. Security Considerations ### 6.1 Token Exposure in Logs + **Severity: Low** Some debug logs might expose sensitive information. @@ -292,16 +304,19 @@ Some debug logs might expose sensitive information. ## Recommendations Priority ### High Priority + 1. **Refactor `transformRequestBody()`** - Break into smaller, focused functions 2. **Create shared cloning utility** - Eliminate duplication across modules 3. **Standardize error handling** - Use consistent Result/Response patterns ### Medium Priority + 1. **Extract model normalization logic** - Use configuration-driven approach 2. **Consolidate text extraction utilities** - Create InputItemUtils class 3. **Centralize magic numbers** - Move to constants with descriptive names ### Low Priority + 1. **Create test utilities** - Reduce test code duplication 2. **Add token sanitization** - Improve security in logging 3. **Optimize string operations** - Use structuredClone consistently @@ -316,4 +331,4 @@ Overall Code Quality Score: **B+ (85/100)** - Code Duplication: C+ (78/100) - Function Complexity: C+ (75/100) - Test Coverage: A (90/100) -- Type Safety: A- (88/100) \ No newline at end of file +- Type Safety: A- (88/100) diff --git a/docs/configuration.md b/docs/configuration.md index f455879..ef1a7ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -432,7 +432,7 @@ CODEX_MODE=1 opencode run "task" # Temporarily enable - [config/full-opencode.json](../config/full-opencode.json) - Complete with 11 variants (adds Codex Mini presets) - [config/minimal-opencode.json](../config/minimal-opencode.json) - Minimal setup -> **Why choose the full config?** OpenCode's auto-compaction and usage widgets rely on the per-model `limit` metadata present only in `full-opencode.json`. Use the minimal config only if you don't need those UI features. +> **Why choose the full config?** OpenCode's usage widgets rely on the per-model `limit` metadata present only in `full-opencode.json`. Use the minimal config only if you don't need those UI features. **Your Configs:** @@ -467,6 +467,22 @@ Look for: } ``` +### Surface warnings to console (opt-in) + +Warnings default to file/app logs only. To mirror warnings to the console/UI for debugging: + +```bash +CODEX_LOG_WARNINGS_TO_CONSOLE=1 opencode run "test" --model=openai/your-model-name +``` + +Or add to `~/.opencode/openhax-codex-config.json`: + +```json +{ + "logging": { "logWarningsToConsole": true } +} +``` + ### Test Per-Model Options ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 370487d..2058710 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -218,7 +218,7 @@ Add this to `~/.config/opencode/opencode.json`: Prompt caching is enabled out of the box: when OpenCode sends its session identifier as `prompt_cache_key`, the plugin forwards it untouched so multi-turn runs reuse prior work. The plugin no longer synthesizes cache IDs; if the host omits that field, Codex treats the run as uncached. The CODEX_MODE bridge prompt bundled with the plugin is kept in sync with the latest Codex CLI release, so the OpenCode UI and Codex share the same tool contract. If you hit your ChatGPT subscription limits, the plugin returns a friendly Codex-style message with the 5-hour and weekly usage windows so you know when capacity resets. -> **Heads up:** OpenCode's context auto-compaction and usage sidebar only work when this full configuration is installed. The minimal configuration skips the per-model limits, so OpenCode cannot display token usage or compact history automatically. +> **Heads up:** OpenCode's usage sidebar relies on the per-model limits in this full configuration. The minimal configuration skips those limits, so token usage may not display correctly. #### Option B: Minimal Configuration diff --git a/docs/notes/2025.11.19.18.38.24.md b/docs/notes/2025.11.19.18.38.24.md index 9bb50f0..01c90b6 100644 --- a/docs/notes/2025.11.19.18.38.24.md +++ b/docs/notes/2025.11.19.18.38.24.md @@ -5,7 +5,6 @@ interface TransformResult so it’s exported from the module, and update any loc references or imports elsewhere if needed to use the exported type (no other logic changes). - In lib/request/request-transformer.ts around lines 621 to 633, the code duplicates the bridge message object creation (the developer role message with CODEX_OPENCODE_BRIDGE and input merging) which is repeated later at lines @@ -16,34 +15,6 @@ duplicated branches with a call to that helper, keeping existing types and imports and ensuring generateContentHash("add") checks still control whether to return the helper result or the original input. - -In lib/compaction/compaction-executor.ts around lines 24 to 66, wrap the -response.text() + JSON.parse(...) and subsequent payload manipulation in a -try/catch so non‑JSON or unexpected response shapes do not crash compaction; on -any parse or processing error, log or ignore the error and return the original -response object untouched. Ensure the catch block returns the original Response -(preserving status, statusText, headers, and body) so callers receive the -unmodified response when parsing fails. - - -In lib/compaction/codex-compaction.ts around lines 168 to 170, the cloneRange -function duplicates logic already implemented in lib/utils/clone.ts as -cloneInputItems; replace the local implementation by importing cloneInputItems -from 'lib/utils/clone' and call it where cloneRange is used (or rename uses to -cloneInputItems), remove the duplicate function, and ensure the import is added -and TypeScript types align with InputItem[]. - - -In lib/compaction/codex-compaction.ts around lines 131 to 144, the -extractTextFromItem function duplicates logic already in -lib/utils/input-item-utils.ts; replace this local implementation by importing -and calling the centralized utility (ensuring the import path is correct), and -if needed adapt or wrap the utility call so behavior remains identical (handle -null/undefined input and array/object type checks the same way as the previous -local function). Remove the duplicated function, run type checks/TS compile and -unit tests to confirm no behavioral regressions. - - lib/cache/cache-metrics.ts lines 34-53 (also apply similar changes at 59-79, 103-105, 167-185): the metrics object and API are tightened to prevent accidental writes to the aggregate bucket but getMetrics currently performs only @@ -55,32 +26,7 @@ return a deep-cloned/read-only snapshot from getMetrics or clearly document the return as read-only to prevent external mutation. In lib/cache/cache-warming.ts around lines 113 to 126, the catch block declares -an unused named parameter (_error) causing lint/typecheck warnings; remove the +an unused named parameter (\_error) causing lint/typecheck warnings; remove the unused binding by changing the catch to a bare catch (i.e., catch { ... }) so the error is still ignored and the function behavior remains identical while satisfying the linter. - -In lib/compaction/codex-compaction.ts around lines 131 to 144, the -extractTextFromItem function duplicates logic already in -lib/utils/input-item-utils.ts; replace this local implementation by importing -and calling the centralized utility (ensuring the import path is correct), and -if needed adapt or wrap the utility call so behavior remains identical (handle -null/undefined input and array/object type checks the same way as the previous -local function). Remove the duplicated function, run type checks/TS compile and -unit tests to confirm no behavioral regressions. - -In lib/compaction/codex-compaction.ts around lines 168 to 170, the cloneRange -function duplicates logic already implemented in lib/utils/clone.ts as -cloneInputItems; replace the local implementation by importing cloneInputItems -from 'lib/utils/clone' and call it where cloneRange is used (or rename uses to -cloneInputItems), remove the duplicate function, and ensure the import is added -and TypeScript types align with InputItem[]. - -In lib/compaction/compaction-executor.ts around lines 24 to 66, wrap the -response.text() + JSON.parse(...) and subsequent payload manipulation in a -try/catch so non‑JSON or unexpected response shapes do not crash compaction; on -any parse or processing error, log or ignore the error and return the original -response object untouched. Ensure the catch block returns the original Response -(preserving status, statusText, headers, and body) so callers receive the -unmodified response when parsing fails. - diff --git a/lib/compaction/codex-compaction.ts b/lib/compaction/codex-compaction.ts deleted file mode 100644 index 21682e7..0000000 --- a/lib/compaction/codex-compaction.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { CODEX_COMPACTION_PROMPT, CODEX_SUMMARY_PREFIX } from "../prompts/codex-compaction.js"; -import type { InputItem } from "../types.js"; -import { cloneInputItems, deepClone } from "../utils/clone.js"; -import { extractTextFromItem } from "../utils/input-item-utils.js"; - -const DEFAULT_TRANSCRIPT_CHAR_LIMIT = 12_000; -const COMMAND_TRIGGERS = ["codex-compact", "compact", "codexcompact", "compactnow"]; - -export interface ConversationSerialization { - transcript: string; - totalTurns: number; - droppedTurns: number; -} - -export interface CompactionBuildResult { - items: InputItem[]; - serialization: ConversationSerialization; -} - -export interface CompactionConfig { - enabled: boolean; - autoLimitTokens?: number; - autoMinMessages?: number; -} - -export function approximateTokenCount(items: InputItem[] | undefined): number { - if (!Array.isArray(items) || items.length === 0) { - return 0; - } - let chars = 0; - for (const item of items) { - chars += extractTextFromItem(item).length; - } - return Math.max(0, Math.ceil(chars / 4)); -} - -export function detectCompactionCommand(input: InputItem[] | undefined): string | null { - if (!Array.isArray(input) || input.length === 0) { - return null; - } - for (let index = input.length - 1; index >= 0; index -= 1) { - const item = input[index]; - if (!item || item.role !== "user") continue; - const content = extractTextFromItem(item).trim(); - if (!content) continue; - const normalized = normalizeCommandTrigger(content); - if (COMMAND_TRIGGERS.some((trigger) => normalized === trigger || normalized.startsWith(`${trigger} `))) { - return normalized; - } - break; - } - return null; -} - -export function serializeConversation( - items: InputItem[] | undefined, - limit = DEFAULT_TRANSCRIPT_CHAR_LIMIT, -): ConversationSerialization { - if (!Array.isArray(items) || items.length === 0) { - return { transcript: "", totalTurns: 0, droppedTurns: 0 }; - } - const conversation: Array<{ role: string; text: string }> = []; - for (const item of items) { - const text = extractTextFromItem(item); - if (!text) continue; - const role = formatRole(item.role); - if (!role) continue; - conversation.push({ role, text }); - } - let totalChars = 0; - const selected: Array<{ role: string; text: string }> = []; - for (let index = conversation.length - 1; index >= 0; index -= 1) { - const entry = conversation[index]; - const chunk = formatEntry(entry.role, entry.text); - selected.push(entry); - totalChars += chunk.length; - if (totalChars >= limit) { - break; - } - } - selected.reverse(); - const transcript = selected.map((entry) => formatEntry(entry.role, entry.text)).join("\n"); - const droppedTurns = Math.max(0, conversation.length - selected.length); - return { transcript, totalTurns: conversation.length, droppedTurns }; -} - -export function buildCompactionPromptItems(transcript: string): InputItem[] { - const compactionMetadata = { source: "opencode-compaction", opencodeCompaction: true }; - const developer: InputItem = { - type: "message", - role: "developer", - content: CODEX_COMPACTION_PROMPT, - metadata: compactionMetadata, - }; - const user: InputItem = { - type: "message", - role: "user", - content: transcript || "(conversation is empty)", - metadata: compactionMetadata, - }; - return [developer, user]; -} - -export function collectSystemMessages(items: InputItem[] | undefined): InputItem[] { - if (!Array.isArray(items)) return []; - return items - .filter((item) => item && (item.role === "system" || item.role === "developer")) - .map((item) => deepClone(item)); -} - -export function createSummaryMessage(summaryText: string): InputItem { - const normalized = summaryText?.trim() ?? "(no summary available)"; - const withPrefix = normalized.startsWith(CODEX_SUMMARY_PREFIX) - ? normalized - : `${CODEX_SUMMARY_PREFIX}\n\n${normalized}`; - return { - type: "message", - role: "user", - content: withPrefix, - }; -} - -export function extractTailAfterSummary(items: InputItem[] | undefined): InputItem[] { - if (!Array.isArray(items) || items.length === 0) return []; - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (!item || item.role !== "user") continue; - const text = extractTextFromItem(item); - if (!text) continue; - return cloneInputItems(items.slice(index)); - } - return []; -} - -function normalizeCommandTrigger(value: string): string { - const trimmed = value.trim().toLowerCase(); - if (!trimmed) return ""; - if (trimmed.startsWith("/") || trimmed.startsWith("?")) { - return trimmed.slice(1).trimStart(); - } - return trimmed; -} - -function formatRole(role: string): string | null { - if (!role) return null; - const lower = role.toLowerCase(); - if (lower === "user" || lower === "assistant") { - return lower === "user" ? "User" : "Assistant"; - } - return null; -} - -function formatEntry(role: string, text: string): string { - return `## ${role}\n${text.trim()}\n`; -} diff --git a/lib/compaction/compaction-executor.ts b/lib/compaction/compaction-executor.ts deleted file mode 100644 index 8f3a6ab..0000000 --- a/lib/compaction/compaction-executor.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { SessionManager } from "../session/session-manager.js"; -import type { InputItem, SessionContext } from "../types.js"; -import { createSummaryMessage } from "./codex-compaction.js"; - -export interface CompactionDecision { - mode: "command" | "auto"; - reason?: string; - approxTokens?: number; - preservedSystem: InputItem[]; - serialization: { - transcript: string; - totalTurns: number; - droppedTurns: number; - }; -} - -interface FinalizeOptions { - response: Response; - decision: CompactionDecision; - sessionManager?: SessionManager; - sessionContext?: SessionContext; -} - -export async function finalizeCompactionResponse({ - response, - decision, - sessionManager, - sessionContext, -}: FinalizeOptions): Promise { - const responseClone = response.clone(); - - try { - const text = await responseClone.text(); - const payload = JSON.parse(text) as any; - const summaryText = extractFirstAssistantText(payload) ?? "(no summary provided)"; - const summaryMessage = createSummaryMessage(summaryText); - const summaryContent = typeof summaryMessage.content === "string" ? summaryMessage.content : ""; - - const metaNote = - decision.mode === "auto" - ? `Auto compaction triggered (${decision.reason ?? "context limit"}). Review the summary below, then resend your last instruction.\n\n` - : ""; - const finalText = `${metaNote}${summaryContent}`.trim(); - - rewriteAssistantOutput(payload, finalText); - payload.metadata = { - ...(payload.metadata ?? {}), - codex_compaction: { - mode: decision.mode, - reason: decision.reason, - dropped_turns: decision.serialization.droppedTurns, - total_turns: decision.serialization.totalTurns, - }, - }; - - if (sessionManager && sessionContext) { - sessionManager.applyCompactionSummary(sessionContext, { - baseSystem: decision.preservedSystem, - summary: summaryContent, - }); - } - - const headers = new Headers(response.headers); - return new Response(JSON.stringify(payload), { - status: response.status, - statusText: response.statusText, - headers, - }); - } catch { - return response; - } -} - -function extractFirstAssistantText(payload: any): string | null { - const output = Array.isArray(payload?.output) ? payload.output : []; - for (const item of output) { - if (item?.role !== "assistant") continue; - const content = Array.isArray(item?.content) ? item.content : []; - for (const part of content) { - if (part?.type === "output_text" && typeof part.text === "string") { - return part.text; - } - } - } - return null; -} - -function rewriteAssistantOutput(payload: any, text: string): void { - const output = Array.isArray(payload?.output) ? payload.output : []; - for (const item of output) { - if (item?.role !== "assistant") continue; - const content = Array.isArray(item?.content) ? item.content : []; - const firstText = content.find((part: any) => part?.type === "output_text"); - if (firstText) { - firstText.text = text; - } - break; - } -} diff --git a/lib/config.ts b/lib/config.ts index 075fcb9..677926c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -14,6 +14,7 @@ const DEFAULT_CONFIG: PluginConfig = { enablePromptCaching: true, logging: { showWarningToasts: false, + logWarningsToConsole: false, }, }; diff --git a/lib/logger.ts b/lib/logger.ts index 4cc6d81..472cd45 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -13,6 +13,7 @@ const envLoggingDefaults = { loggingEnabled: process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1", debugFlagEnabled: process.env.DEBUG_CODEX_PLUGIN === "1", showWarningToasts: process.env.CODEX_SHOW_WARNING_TOASTS === "1", + logWarningsToConsole: process.env.CODEX_LOG_WARNINGS_TO_CONSOLE === "1", logRotationMaxBytes: getEnvNumber("CODEX_LOG_MAX_BYTES", 5 * 1024 * 1024), logRotationMaxFiles: getEnvNumber("CODEX_LOG_MAX_FILES", 5), logQueueMaxLength: getEnvNumber("CODEX_LOG_QUEUE_MAX", 1000), @@ -24,6 +25,7 @@ export function isLoggingEnabled(): boolean { } let DEBUG_FLAG_ENABLED = envLoggingDefaults.debugFlagEnabled; let WARN_TOASTS_ENABLED = envLoggingDefaults.showWarningToasts ?? false; +let WARN_CONSOLE_ENABLED = envLoggingDefaults.logWarningsToConsole ?? false; let LOG_ROTATION_MAX_BYTES = Math.max(1, envLoggingDefaults.logRotationMaxBytes); let LOG_ROTATION_MAX_FILES = Math.max(1, envLoggingDefaults.logRotationMaxFiles); let LOG_QUEUE_MAX_LENGTH = Math.max(1, envLoggingDefaults.logQueueMaxLength); @@ -82,6 +84,7 @@ function applyLoggingOverrides(logging?: LoggingConfig): void { LOGGING_ENABLED = logging.enableRequestLogging ?? LOGGING_ENABLED; DEBUG_FLAG_ENABLED = logging.debug ?? DEBUG_FLAG_ENABLED; WARN_TOASTS_ENABLED = logging.showWarningToasts ?? WARN_TOASTS_ENABLED; + WARN_CONSOLE_ENABLED = logging.logWarningsToConsole ?? WARN_CONSOLE_ENABLED; LOG_ROTATION_MAX_BYTES = ensurePositiveNumber(logging.logMaxBytes, LOG_ROTATION_MAX_BYTES); LOG_ROTATION_MAX_FILES = ensurePositiveNumber(logging.logMaxFiles, LOG_ROTATION_MAX_FILES); LOG_QUEUE_MAX_LENGTH = ensurePositiveNumber(logging.logQueueMax, LOG_QUEUE_MAX_LENGTH); @@ -181,7 +184,8 @@ function emit(level: LogLevel, message: string, extra?: Record) extra: sanitizedExtra, }; - if (LOGGING_ENABLED || DEBUG_ENABLED) { + const shouldPersist = LOGGING_ENABLED || DEBUG_ENABLED || level === "warn"; + if (shouldPersist) { appendRollingLog(entry); } @@ -204,7 +208,10 @@ function emit(level: LogLevel, message: string, extra?: Record) notifyToast(level, message, sanitizedExtra); } - const shouldLogToConsole = level !== "warn" || !warnToastEnabled; + const shouldLogToConsole = + level === "warn" + ? WARN_CONSOLE_ENABLED && !warnToastEnabled + : level === "error" || CONSOLE_LOGGING_ENABLED; if (shouldLogToConsole) { logToConsole(level, message, sanitizedExtra); } diff --git a/lib/prompts/codex-compaction.ts b/lib/prompts/codex-compaction.ts deleted file mode 100644 index 56e8f4c..0000000 --- a/lib/prompts/codex-compaction.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const CODEX_COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. - -Include: -- Current progress and key decisions made -- Important context, constraints, or user preferences -- What remains to be done (clear next steps) -- Any critical data, examples, or references needed to continue - -Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`; - -export const CODEX_SUMMARY_PREFIX = `Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:`; diff --git a/lib/request/compaction-helpers.ts b/lib/request/compaction-helpers.ts deleted file mode 100644 index f1c9810..0000000 --- a/lib/request/compaction-helpers.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { - approximateTokenCount, - buildCompactionPromptItems, - collectSystemMessages, - serializeConversation, -} from "../compaction/codex-compaction.js"; -import type { CompactionDecision } from "../compaction/compaction-executor.js"; -import { filterInput } from "./input-filters.js"; -import type { InputItem, RequestBody } from "../types.js"; -import { countConversationTurns } from "../utils/input-item-utils.js"; - -export interface CompactionSettings { - enabled: boolean; - autoLimitTokens?: number; - autoMinMessages?: number; -} - -export interface CompactionOptions { - settings: CompactionSettings; - commandText: string | null; - originalInput: InputItem[]; - preserveIds?: boolean; -} - -/** - * Drop only the latest user message (e.g., a compaction command) while preserving any later assistant/tool items. - */ -function removeLastUserMessage(items: InputItem[]): InputItem[] { - for (let index = items.length - 1; index >= 0; index -= 1) { - if (items[index]?.role === "user") { - return [...items.slice(0, index), ...items.slice(index + 1)]; - } - } - return items; -} - -function maybeBuildCompactionPrompt( - originalInput: InputItem[], - commandText: string | null, - settings: CompactionSettings, -): { items: InputItem[]; decision: CompactionDecision } | null { - if (!settings.enabled) { - return null; - } - const conversationSource = commandText ? removeLastUserMessage(originalInput) : originalInput; - const turnCount = countConversationTurns(conversationSource); - let trigger: "command" | "auto" | null = null; - let reason: string | undefined; - let approxTokens: number | undefined; - - if (commandText) { - trigger = "command"; - } else if (settings.autoLimitTokens && settings.autoLimitTokens > 0) { - approxTokens = approximateTokenCount(conversationSource); - const minMessages = settings.autoMinMessages ?? 8; - if (approxTokens >= settings.autoLimitTokens && turnCount >= minMessages) { - trigger = "auto"; - reason = `~${approxTokens} tokens >= limit ${settings.autoLimitTokens}`; - } - } - - if (!trigger) { - return null; - } - - const serialization = serializeConversation(conversationSource); - const promptItems = buildCompactionPromptItems(serialization.transcript); - - return { - items: promptItems, - decision: { - mode: trigger, - reason, - approxTokens, - preservedSystem: collectSystemMessages(originalInput), - serialization, - }, - }; -} - -export function applyCompactionIfNeeded( - body: RequestBody, - compactionOptions?: CompactionOptions, -): CompactionDecision | undefined { - if (!compactionOptions?.settings.enabled) { - return undefined; - } - - const compactionBuild = maybeBuildCompactionPrompt( - compactionOptions.originalInput, - compactionOptions.commandText, - compactionOptions.settings, - ); - - if (!compactionBuild) { - return undefined; - } - - const preserveIds = compactionOptions.preserveIds ?? false; - body.input = filterInput(compactionBuild.items, { preserveIds, preserveMetadata: true }); - delete (body as any).tools; - delete (body as any).tool_choice; - delete (body as any).parallel_tool_calls; - - return compactionBuild.decision; -} diff --git a/lib/request/input-filters.ts b/lib/request/input-filters.ts index 9ad36a3..d14fc55 100644 --- a/lib/request/input-filters.ts +++ b/lib/request/input-filters.ts @@ -81,80 +81,6 @@ export async function filterOpenCodeSystemPrompts( // Fallback to text-based detection only } - const compactionInstructionPatterns: RegExp[] = [ - /(summary[ _-]?file)/i, - /(summary[ _-]?path)/i, - /summary\s+(?:has\s+been\s+)?saved\s+(?:to|at)/i, - /summary\s+(?:is\s+)?stored\s+(?:in|at|to)/i, - /summary\s+(?:is\s+)?available\s+(?:at|in)/i, - /write\s+(?:the\s+)?summary\s+(?:to|into)/i, - /save\s+(?:the\s+)?summary\s+(?:to|into)/i, - /open\s+(?:the\s+)?summary/i, - /read\s+(?:the\s+)?summary/i, - /cat\s+(?:the\s+)?summary/i, - /view\s+(?:the\s+)?summary/i, - /~\/\.opencode/i, - /\.opencode\/.*summary/i, - ]; - - const hasCompactionMetadataFlag = (item: InputItem): boolean => { - const rawMeta = (item as Record)?.metadata ?? (item as Record)?.meta; - if (!rawMeta || typeof rawMeta !== "object") return false; - const meta = rawMeta as Record; - const metaAny = meta as Record; - const source = metaAny.source as unknown; - if (typeof source === "string" && source.toLowerCase() === "opencode-compaction") { - return true; - } - if (metaAny.opencodeCompaction === true || metaAny.opencode_compaction === true) { - return true; - } - return false; - }; - - const matchesCompactionInstruction = (value: string): boolean => - compactionInstructionPatterns.some((pattern) => pattern.test(value)); - - const sanitizeOpenCodeCompactionPrompt = (item: InputItem): InputItem | null => { - const text = extractTextFromItem(item); - if (!text) return null; - const sanitizedText = text - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter((line) => { - const trimmed = line.trim(); - if (!trimmed) { - return true; - } - return !matchesCompactionInstruction(trimmed); - }) - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); - if (!sanitizedText) { - return null; - } - const originalMentionedCompaction = /\bauto[-\s]?compaction\b/i.test(text); - let finalText = sanitizedText; - if (originalMentionedCompaction && !/\bauto[-\s]?compaction\b/i.test(finalText)) { - finalText = `Auto-compaction summary\n\n${finalText}`; - } - return { - ...item, - content: finalText, - }; - }; - - const isOpenCodeCompactionPrompt = (item: InputItem): boolean => { - const isSystemRole = item.role === "developer" || item.role === "system"; - if (!isSystemRole) return false; - const text = extractTextFromItem(item); - if (!text) return false; - const hasCompaction = /\b(auto[-\s]?compaction|compaction|compact)\b/i.test(text); - const hasSummary = /\b(summary|summarize|summarise)\b/i.test(text); - return hasCompaction && hasSummary && matchesCompactionInstruction(text); - }; - const filteredInput: InputItem[] = []; for (const item of input) { if (item.role === "user") { @@ -166,15 +92,6 @@ export async function filterOpenCodeSystemPrompts( continue; } - const compactionMetadataFlagged = hasCompactionMetadataFlag(item); - if (compactionMetadataFlagged || isOpenCodeCompactionPrompt(item)) { - const sanitized = sanitizeOpenCodeCompactionPrompt(item); - if (sanitized) { - filteredInput.push(sanitized); - } - continue; - } - filteredInput.push(item); } diff --git a/lib/session/session-manager.ts b/lib/session/session-manager.ts index c1c641a..94991c1 100644 --- a/lib/session/session-manager.ts +++ b/lib/session/session-manager.ts @@ -3,7 +3,7 @@ import { SESSION_CONFIG } from "../constants.js"; import { logDebug, logWarn } from "../logger.js"; import { PROMPT_CACHE_FORK_KEYS } from "../request/prompt-cache.js"; import type { CodexResponsePayload, InputItem, RequestBody, SessionContext, SessionState } from "../types.js"; -import { cloneInputItems, deepClone } from "../utils/clone.js"; +import { cloneInputItems } from "../utils/clone.js"; import { isAssistantMessage, isUserMessage } from "../utils/input-item-utils.js"; export interface SessionManagerOptions { @@ -453,13 +453,8 @@ export class SessionManager { lastUpdated: Date.now(), lastCachedTokens: state.lastCachedTokens, bridgeInjected: state.bridgeInjected, - compactionBaseSystem: state.compactionBaseSystem - ? cloneInputItems(state.compactionBaseSystem) - : undefined, - compactionSummaryItem: state.compactionSummaryItem - ? deepClone(state.compactionSummaryItem) - : undefined, }; + this.sessions.set(forkSessionId, forkState); logWarn("SessionManager: prefix mismatch detected, forking session", { sessionId: state.id, @@ -492,39 +487,6 @@ export class SessionManager { return context; } - public applyCompactionSummary( - context: SessionContext | undefined, - payload: { baseSystem: InputItem[]; summary: string }, - ): void { - if (!context?.enabled) return; - const state = context.state; - state.compactionBaseSystem = cloneInputItems(payload.baseSystem); - state.compactionSummaryItem = deepClone({ - type: "message", - role: "user", - content: payload.summary, - }); - } - - public applyCompactedHistory( - body: RequestBody, - context: SessionContext | undefined, - opts?: { skip?: boolean }, - ): void { - if (!context?.enabled || opts?.skip) { - return; - } - const baseSystem = context.state.compactionBaseSystem; - const summary = context.state.compactionSummaryItem; - if (!baseSystem || !summary) { - return; - } - const tail = extractLatestUserSlice(body.input); - const merged = [...cloneInputItems(baseSystem), deepClone(summary), ...tail]; - // eslint-disable-next-line no-param-reassign - body.input = merged; - } - public recordResponse( context: SessionContext | undefined, payload: CodexResponsePayload | undefined, diff --git a/lib/types.ts b/lib/types.ts index 8d94ebf..9567d9c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -30,6 +30,8 @@ export interface LoggingConfig { debug?: boolean; /** Whether warning-level toasts should be shown (default: false) */ showWarningToasts?: boolean; + /** Whether warnings should also be mirrored to console (default: false) */ + logWarningsToConsole?: boolean; /** Override max bytes before rolling log rotation */ logMaxBytes?: number; /** Override number of rotated log files to keep */ @@ -193,8 +195,6 @@ export interface SessionState { lastUpdated: number; lastCachedTokens?: number; bridgeInjected?: boolean; // Track whether Codex-OpenCode bridge prompt was added - compactionBaseSystem?: InputItem[]; - compactionSummaryItem?: InputItem; } /** diff --git a/spec/auto-compaction-summary.md b/spec/auto-compaction-summary.md deleted file mode 100644 index 26cf853..0000000 --- a/spec/auto-compaction-summary.md +++ /dev/null @@ -1,32 +0,0 @@ -# Auto Compaction Summary Delivery - -## Context -- Users report that after OpenCode auto compaction fires, Codex-based agents respond with messages like `I don’t see the “above summary” you mentioned`, meaning the summarised context never reaches the model. -- CODEX_MODE currently strips any developer/system message that matches the auto-compaction heuristic in `filterOpenCodeSystemPrompts`, so the summary payload gets dropped before the bridge prompt or user instruction runs. - -## Affected Code -- `lib/request/request-transformer.ts:539-592` — `filterOpenCodeSystemPrompts()` removes messages detected by `isOpenCodeCompactionPrompt`, with no sanitisation or pass-through, so summaries disappear altogether. -- `test/request-transformer.test.ts:505-583` — lacks coverage for compaction prompts, so regressions around summary preservation go unnoticed. - -## External Signals -- GitHub issue [sst/opencode#2945](https://github.com/sst/opencode/issues/2945) discusses context loss after compaction and gives us a user-facing reproduction. -- Direct user transcript provided in this task highlights Codex replying “I don’t see the above summary,” confirming summaries are filtered before they ever reach the agent. - -## Requirements -1. Detect OpenCode compaction prompts but **sanitize** them instead of wholesale removal: - - Keep the actual summary text in the conversation. - - Strip only noisy guidance about nonexistent summary files or paths. - - Maintain developer-role metadata so downstream logic (bridge prompt injection, etc.) still works. -2. If a compaction prompt contains nothing except invalid file instructions, drop it to avoid confusing the agent. -3. Add regression tests covering: - - Summary text survives compaction filtering while path instructions are removed. - - Pure file-instruction prompts (no summary content) are still discarded. -4. Document behaviour inline so future updates know why compaction prompts are rewritten rather than discarded. - -## Definition of Done -- Running `npm test` locally covers the new cases and passes. -- Auto-compaction messages in live sessions now show summaries instead of “missing summary” errors, verified by inspecting transformed input in unit tests (and optionally via manual logging). -- Spec updated with decisions (this file) and commit references once implemented. - -## Changelog -- 2025-11-16: Implemented sanitized compaction prompt handling, preserved summaries, and added regression tests covering both summary retention and pure instruction drops. diff --git a/spec/cache-analysis.md b/spec/cache-analysis.md index 15ea8e9..a10029e 100644 --- a/spec/cache-analysis.md +++ b/spec/cache-analysis.md @@ -1,9 +1,11 @@ # Cache Comparison Analysis Spec ## Objective + Summarize caching behaviors across this plugin, the upstream `openai/codex` CLI, and the `sst/opencode` runtime to identify potential cache correctness issues (prompt caching, instruction caching, session reuse) that could affect bridging Codex into OpenCode. ## Code References + - `lib/cache/session-cache.ts:32-114` – local TTL-based session cache implementation with eviction metrics hooks. - `lib/cache/cache-warming.ts:30-151` – startup warming sequence and warm-state probes. - `lib/prompts/codex.ts:20-158` – GitHub-backed Codex instruction caching (15 min TTL, release tag probes, bundled fallback). @@ -13,31 +15,36 @@ Summarize caching behaviors across this plugin, the upstream `openai/codex` CLI, - `index.ts:124-211` – how warm caches + session manager integrate into fetch flow. ### Upstream `openai/codex` + - `codex-rs/core/src/client.rs:L246-L268` – always attaches `prompt_cache_key` = conversation ID; handles `store` toggling per provider. - `codex-rs/core/src/client_common.rs:L276-L331` – payload structure includes `prompt_cache_key`, `store`, reasoning + verbosity defaults. - `codex-rs/core/tests/suite/prompt_caching.rs:L481-L640` – reference behavior for cache reuse, prefix consistency, and overrides. - `codex-rs/core/src/conversation_manager.rs:L96-L251` – lifecycle for sessions, fork handling, and history reuse guarantees. ### `sst/opencode` + - `packages/opencode/src/provider/transform.ts:L86-L118` – provider option shaping, automatic `promptCacheKey` assignment, and runtime-specific defaults (`include`, `reasoningSummary`). ## Existing Issues / PRs + - Issues are disabled on this repository (`gh issue list -L 5`). -- Open PRs: #2 `this is a thing` (branch `bug-fix/compaction`, opened 2025-11-11T01:50:35Z). ## Requirements + 1. Map cache responsibilities for instructions, prompts, and session state across all three runtimes. 2. Highlight behavioral gaps where this plugin diverges from Codex CLI guarantees (e.g., prompt prefix stability, `prompt_cache_key` management, TTL policies). 3. Contrast with OpenCode runtime expectations (session IDs, provider defaults) to flag integration risks. 4. Produce actionable list of potential caching issues plus validation steps. ## Definition of Done + - Written comparison covering instruction caching, prompt caching, bridge prompt deduping, and session cache key management. - At least three concrete issue hypotheses backed by file references (include upstream references where applicable). - Recommendations for instrumentation or tests to validate each hypothesis. - Proposed validation/mitigation steps align with both Codex CLI behavior and OpenCode runtime constraints. ## Plan (Phases) + 1. **Discovery** – Review this repo's cache modules, session manager, and request transformer (completed per references above). 2. **Upstream Baseline** – Document how `openai/codex` handles prompt caching/session reuse (client + tests reviewed). 3. **Runtime Contrast** – Capture relevant parts of `sst/opencode` provider transformations impacting caching. @@ -48,16 +55,19 @@ Summarize caching behaviors across this plugin, the upstream `openai/codex` CLI, ## Phase 3 – Runtime Contrast Findings ### Instruction Caching Responsibilities + - **Plugin (`lib/prompts/codex.ts:20-158`)** – Fetches the Codex CLI instructions straight from the latest GitHub release, writes them to `~/.opencode/cache/codex-instructions.md`, and mirrors them into an in-memory session cache under both the release-specific key (`codex:{etag}:{tag}`) and the sentinel key `"latest"`. Cache warming (`lib/cache/cache-warming.ts:30-94`) simply calls these fetchers and records the result, so the plugin is responsible for both persistence and warm-up heuristics. - **Codex CLI (`codex-rs/core/src/client.rs`, payload builder around `ResponsesApiRequest`)** – Ships the instructions with the binary and always injects them per request via `prompt.get_full_instructions()`. There is no runtime fetch, which means the CLI never risks network failures while pulling the prompt, but it also means end users must upgrade the CLI to pick up a new instruction release. - **OpenCode Runtime (`packages/opencode/src/provider/transform.ts`)** – Delegates instruction management entirely to the provider. The runtime does not attempt to cache Codex instructions; it only sets provider options (e.g., `promptCacheKey`, `include`, `reasoningSummary`). When this plugin is active, OpenCode expects the provider (us) to guarantee that instruction caching matches Codex's expectations. ### Prompt + Session Caching Responsibilities + - **Codex CLI** – Always sets `prompt_cache_key = conversation_id` on every Responses API call and keeps the entire prefix (instructions + environment context + full history) byte-identical between turns (`codex-rs/core/tests/suite/prompt_caching.rs`). This guarantees that the backend cache can reuse encrypted reasoning and prefix tokens whenever the key repeats. -- **OpenCode Runtime** – Uses `ProviderTransform.options()` to set `promptCacheKey = sessionID` (for both the built-in OpenCode provider and OpenAI-compatible providers) and to force `store: false`, `include: ["reasoning.encrypted_content"]`, and `reasoningSummary: "auto"` when targeting gpt-5-family models. OpenCode *assumes* that the downstream provider will faithfully reuse the prefix that corresponds to this key. +- **OpenCode Runtime** – Uses `ProviderTransform.options()` to set `promptCacheKey = sessionID` (for both the built-in OpenCode provider and OpenAI-compatible providers) and to force `store: false`, `include: ["reasoning.encrypted_content"]`, and `reasoningSummary: "auto"` when targeting gpt-5-family models. OpenCode _assumes_ that the downstream provider will faithfully reuse the prefix that corresponds to this key. - **Plugin** – Extracts host-provided keys from either `prompt_cache_key`, `promptCacheKey`, or nested metadata (`lib/request/request-transformer.ts:692-706`) and, if prompt caching is enabled, replaces them with a sanitized per-session key maintained by `SessionManager` (`lib/session/session-manager.ts:117-214`). Prefix tracking relies on the exact JSON structure of the filtered input (`filterInput` strips IDs but leaves metadata intact), and prefix mismatches trigger a new random `prompt_cache_key` via `resetSessionInternal()`. ### Cache Warm / Diagnostics Responsibilities + - `warmCachesOnStartup()` cleans expired entries, fetches Codex + OpenCode prompts, and records which cache warmed (`lib/cache/cache-warming.ts:30-94`). - `areCachesWarm()` and the `/codex-metrics` command rely on sentinel keys (`"latest"` and `"main"`) instead of TTL metadata, so a cache entry is considered warm as long as it still lives in memory, regardless of whether the underlying ETag is stale. - `getCacheWarmingStats()` currently re-invokes the fetchers, which can trigger additional network requests even when the caller only needs a snapshot—unlike the Codex CLI, which never has to re-fetch instructions for diagnostics. @@ -67,7 +77,8 @@ Summarize caching behaviors across this plugin, the upstream `openai/codex` CLI, ## Phase 4 – Synthesis & Issue Hypotheses ### 1. Prompt caching is opt-in, unlike Codex CLI defaults -- **Evidence**: `index.ts:123-125` instantiates `SessionManager` with `enabled = pluginConfig.enablePromptCaching ?? false`, so caching is *disabled* unless users flip a config switch. Codex CLI always attaches a `prompt_cache_key` (`codex-rs/core/src/client.rs`, `ResponsesApiRequest` builder) and therefore guarantees cache reuse. + +- **Evidence**: `index.ts:123-125` instantiates `SessionManager` with `enabled = pluginConfig.enablePromptCaching ?? false`, so caching is _disabled_ unless users flip a config switch. Codex CLI always attaches a `prompt_cache_key` (`codex-rs/core/src/client.rs`, `ResponsesApiRequest` builder) and therefore guarantees cache reuse. - **Risk**: Any OpenCode workflow that forgets to set `promptCacheKey` (custom providers, tests, future refactors) will run fully stateless through this plugin, even though we aggressively strip IDs and system prompts. That yields zero cache hits and higher token usage than either OpenCode or the Codex CLI expect. - **Mitigation / Validation**: - Default `enablePromptCaching` to `true`, or automatically fall back to `SessionManager` when no host key is present. @@ -76,6 +87,7 @@ Summarize caching behaviors across this plugin, the upstream `openai/codex` CLI, - **Mitigation status**: Implemented via `index.ts:121-130` (prompt caching default + warning) and `lib/request/request-transformer.ts:645-712` (auto-derives or generates `prompt_cache_key`), with regression tests in `test/request-transformer.test.ts:546-586`. ### 2. Prefix comparisons include volatile metadata, causing spurious cache resets + - **Evidence**: `sharesPrefix()` (`lib/session/session-manager.ts:38-57`) uses `JSON.stringify` over the entire filtered input. `filterInput()` (`lib/request/request-transformer.ts:389-412`) removes IDs but keeps every `metadata` object untouched. OpenCode frequently stamps messages with per-turn metadata (trace IDs, sandbox policy diffs, file lists), so two logically identical prefixes may fail the byte-for-byte comparison even though only metadata changed. Codex CLI avoids this by constructing the prefix itself (see the `prompt_caching.rs` tests verifying exact prefix reuse even when environment overrides apply). - **Risk**: Every metadata mutation forces `SessionManager` to call `resetSessionInternal(..., true)`, generating a brand-new `prompt_cache_key`. That makes cache hit rates fall toward zero, exactly the problem the CLI tests guard against. - **Mitigation / Validation**: @@ -85,6 +97,7 @@ Summarize caching behaviors across this plugin, the upstream `openai/codex` CLI, - **Mitigation status**: `filterInput()` now removes metadata when operating in stateless mode (`lib/request/request-transformer.ts:389-421`) and `test/request-transformer.test.ts:245-280` guards the behavior; IDs/metadata are only preserved when `preserveIds` is true to keep host-managed sessions stable. ### 3. Session cache never evicts, diverging from Codex conversation lifecycle + - **Evidence**: `SessionManager` stores every conversation in an in-memory `Map` with no TTL or size cap (`lib/session/session-manager.ts:108-214, 273-313`). There is no `remove` call anywhere in the plugin. In contrast, the Codex CLI `ConversationManager` exposes `remove_conversation()` (`codex-rs/core/src/conversation_manager.rs`) and reuses conversations created via CLI flows, so memory usage is bounded by active chats. - **Risk**: An OpenCode user who runs many short-lived sessions (e.g., multiple `opencode run` commands) will accumulate unbounded session state inside the plugin process, eventually degrading cache lookup time or exhausting memory. Worse, `/codex-metrics` will report stale "recent sessions" even after the conversations are gone, obscuring real cache health. - **Mitigation / Validation**: @@ -94,6 +107,7 @@ Summarize caching behaviors across this plugin, the upstream `openai/codex` CLI, - **Mitigation status**: `lib/session/session-manager.ts:1-215` now enforces `SESSION_IDLE_TTL_MS` + `SESSION_MAX_ENTRIES`, pruning maps on every `getContext()` call, and `test/session-manager.test.ts:152-197` verifies idle/overflow eviction scenarios. ### 4. Diagnostics may trigger unwanted network fetches + - **Evidence**: `getCacheWarmingStats()` (`lib/cache/cache-warming.ts:121-149`) calls `getCodexInstructions()` and `getOpenCodeCodexPrompt()`, both of which perform ETag-guarded network requests when TTL has expired. Codex CLI diagnostics run entirely offline because the instructions are bundled. - **Risk**: Invoking a diagnostics endpoint (or `/codex-metrics` once it exposes warm stats) could accidentally spam GitHub, undermining the "zero network" goal of the command and masking real cold-start issues. - **Mitigation / Validation**: diff --git a/spec/complexity-reduction.md b/spec/complexity-reduction.md index 0174bfd..0b02329 100644 --- a/spec/complexity-reduction.md +++ b/spec/complexity-reduction.md @@ -27,6 +27,6 @@ ## Plan (Phases) 1. **Prompt Fetchers**: Refactor `getCodexInstructions` and `getOpenCodeCodexPrompt` by extracting helper routines for cache reads/writes, freshness checks, and network fetch handling to reduce branching. -2. **Request Transformation**: Break down `transformRequestForCodex` and tool normalization into smaller helpers (e.g., compaction config, logging wrappers, tool converters) to simplify flow. +2. **Request Transformation**: Break down `transformRequestForCodex` and tool normalization into smaller helpers (e.g., logging wrappers, tool converters) to simplify flow. 3. **Error/Reasoning Handling**: Simplify `handleErrorResponse` and `getReasoningConfig` with helper functions and clearer rule tables; ensure messaging and rate-limit parsing stay intact. 4. **Validation**: Run targeted lint/tests to confirm complexity warnings resolved and behavior intact. diff --git a/spec/issue-triage-2025-11-20.md b/spec/issue-triage-2025-11-20.md deleted file mode 100644 index 333e4e9..0000000 --- a/spec/issue-triage-2025-11-20.md +++ /dev/null @@ -1,23 +0,0 @@ -# Issue Triage — 2025-11-20 - -Scope: Verify status of open issues #6, #23, #22, #21, #39, #24, #40 against current main branch. - -## Findings - -- #23 SessionManager fork sync — Not done. `lib/session/session-manager.ts` extractForkIdentifier only checks `forkId|fork_id|branchId|branch_id` (lines ~120-143); does not consider `parentConversationId|parent_conversation_id` used in prompt cache derivation. -- #22 Compaction metadata flag — Not done. `lib/request/input-filters.ts` uses regex heuristics only to detect OpenCode compaction prompts (lines ~82-139); no metadata flag preferred path. -- #21 Summary-aware tail extraction — Not done. `lib/compaction/codex-compaction.ts` `extractTailAfterSummary` returns slice from last `user` message (lines ~120-129); no summary marker awareness. -- #24 Tests clarify tail semantics — Not done. `test/codex-compaction.test.ts` still names test "extracts tail after the latest user summary message" and asserts last-user behavior (lines ~80-89). -- #39 README installation section missing — Not done. README links to `#installation` (e.g., line ~531) but no `## Installation` heading exists. -- #40 Model stats HTML dashboard server — Not started. No references to "dashboard"/"stats html" in repo. -- #6 Richer metrics/inspect commands — Still blocked by upstream; no new implementation detected. - -## Definition of Done (per issue) - -- #23: Session key fork detection matches prompt cache fork hints (`parentConversationId` variants) with tests. -- #22: Input filtering prefers explicit metadata flag for OpenCode compaction prompts, falling back to heuristics. -- #21: Tail extraction skips summary-marked items; tests updated. -- #24: Tests renamed/rewritten to reflect current semantics and cover summary-aware path once added. -- #39: README gains actual Installation section and linked anchor. -- #40: Dashboard server implemented or scoped; code/tests/docs added. -- #6: Upstream dependency resolved; enhanced metrics/inspect commands implemented and tested. diff --git a/spec/log-warnings-default-file-only.md b/spec/log-warnings-default-file-only.md new file mode 100644 index 0000000..d06001b --- /dev/null +++ b/spec/log-warnings-default-file-only.md @@ -0,0 +1,39 @@ +# Log Warnings Default to File + +## Context + +OpenCode renders console warnings inline, causing severe UI clutter (see reported screenshot). The plugin currently logs warnings to console by default (e.g., personal-use notice in `index.ts:69-73` and other `logWarn` calls), even when request logging/debug flags are off. We need the default behavior to keep warnings out of the UI while still recording them to disk/app logs as appropriate. + +## Relevant Files & Pointers + +- `lib/logger.ts:12-111` — env defaults and logging flags; `WARN_TOASTS_ENABLED` and console toggles derived here. +- `lib/logger.ts:172-211` — `emit` decides forwarding to app log, toasts, and console (warnings currently mirrored to console by default). +- `lib/logger.ts:284-319` — `logToConsole` behavior; logs warn/error unconditionally. +- `index.ts:66-118` — plugin boot emits personal-use warning via `logWarn` after logger configuration. +- `lib/types.ts:26-39` — `LoggingConfig` fields (currently no toggle for console warnings). +- `test/logger.test.ts:140-229` — expectations for warn behavior (console, toasts) that will need updates. +- `lib/config.ts:12-18` — default config includes `logging.showWarningToasts: false`. + +## Existing Issues / PRs + +- None identified in repo related to warning display/logging defaults. + +## Definition of Done + +- Warning logs are not sent to console/UI by default; they are recorded to file/app logs without cluttering the terminal. +- Opt-in mechanism exists to surface warning logs to console/UX when desired. +- Personal-use and other warning emissions follow the new default and do not regress logging reliability. +- Tests updated/added to cover the new default and opt-in paths. + +## Requirements + +- Default: warnings persist to disk/app logs without console output; errors remain console-visible. +- Provide a config/env switch to re-enable console warnings for debugging or when toasts are desired. +- Preserve existing toast support (`showWarningToasts`) and avoid duplicate surfaces (toast + console). +- Maintain existing log rotation/queue behaviors and non-intrusive behavior in test envs. + +## Plan (Phases) + +- **Phase 1: Analysis** — Confirm logger state derivations and warning pathways; decide switch shape (config/env) to keep warn off console by default while allowing opt-in. +- **Phase 2: Implementation** — Update logger defaults/emit logic + config schema to make warn-to-console opt-in and ensure file/app logging retains warnings. +- **Phase 3: Validation** — Refresh tests for new defaults and opt-in behavior; run targeted logger suite (and related) to ensure changes pass. diff --git a/spec/merge-conflict-resolution.md b/spec/merge-conflict-resolution.md index 55927e2..90ecd40 100644 --- a/spec/merge-conflict-resolution.md +++ b/spec/merge-conflict-resolution.md @@ -1,6 +1,7 @@ # Merge Conflict Resolution Plan (ops/release-workflow) ## Context + - Branch: `ops/release-workflow` with merge state and unmerged paths. - Conflicted files (from `git diff --name-only --diff-filter=U`): - `.github/workflows/pr-auto-base.yml` @@ -18,17 +19,19 @@ - `test/session-manager.test.ts` ## Notable conflict locations (line references from current workspace) + - `index.ts`: bridge fetch creation formatting and indentation around ~126-148. - `lib/logger.ts`: toast/app log forwarding logic around ~142-178. - `lib/prompts/codex.ts`: cache metadata handling and ETag logic around ~177-270. - `lib/prompts/opencode-codex.ts`: cache migration/ETag fetch helpers around ~88-357. -- `lib/request/fetch-helpers.ts`: compaction settings and error enrichment around ~166-470. -- `lib/request/request-transformer.ts`: imports, compaction, prompt cache key, bridge/tool injection across file (multiple conflicts starting near top and ~620-1210). +- `lib/request/fetch-helpers.ts`: settings and error enrichment around ~166-470. +- `lib/request/request-transformer.ts`: imports, prompt cache key, bridge/tool injection across file (multiple conflicts starting near top and ~620-1210). - Workflows: `pr-auto-base.yml` trigger/permissions/checkout around ~5-53; `staging-release-prep.yml` release branch/tag creation and PR automation around ~25-296. - Config/test files: `eslint.config.mjs` test overrides (~95-100); `test/logger.test.ts` toast/console expectations (~1-190); `test/session-manager.test.ts` metrics variable naming (~159-165); `package.json` & `package-lock.json` version bump (0.3.0 vs 0.2.0). ## Definition of Done -- All merge conflicts resolved with cohesive logic that preserves newer behaviors (cache handling, logging/toast routing, compaction settings, workflow automation, version 0.3.0). + +- All merge conflicts resolved with cohesive logic that preserves newer behaviors (cache handling, logging/toast routing, workflow automation, version 0.3.0). - TypeScript sources compile conceptually (no mixed indentation or stale references). - Package metadata consistent across `package.json` and `package-lock.json`. - Workflow YAML passes basic syntax review. @@ -36,24 +39,29 @@ - `git status` clean of conflict markers; ready for commit. ## Plan (phased) + ### Phase 1 – Workflows & Config + - Merge `.github/workflows/pr-auto-base.yml` to include checkout + sync/reopen triggers, correct permissions, GH repo usage. - Merge `.github/workflows/staging-release-prep.yml` retaining branch/tag push and auto-merge reviewer steps. - Restore `eslint.config.mjs` test overrides for max-lines. ### Phase 2 – Core Source Merges + - Align `index.ts` fetch creator call with repository style (spaces, no tabs). - Resolve `lib/logger.ts` to avoid duplicate warn logging when toast available while still forwarding error logging. - Merge `lib/prompts/codex.ts` with unified cache metadata handling and fallback semantics. - Merge `lib/prompts/opencode-codex.ts` using fresh cache/ETag helpers and migration checks. -- Merge `lib/request/fetch-helpers.ts` compaction settings builder and enriched error handling using helper functions. -- Merge `lib/request/request-transformer.ts` (imports, prompt cache handling, compaction options, bridge/tool injection) ensuring Codex-mode defaults and logging. +- Merge `lib/request/fetch-helpers.ts` settings builder and enriched error handling using helper functions. +- Merge `lib/request/request-transformer.ts` (imports, prompt cache handling, bridge/tool injection) ensuring Codex-mode defaults and logging. ### Phase 3 – Packages & Tests + - Set version to 0.3.0 in `package.json` and `package-lock.json`; keep dependency blocks aligned. - Update `test/logger.test.ts` to match toast + logging behavior and `OpencodeClient` typing. - Fix `test/session-manager.test.ts` minor variable naming conflict. ### Phase 4 – Verification + - Run targeted tests if time allows (logger/session transformer) via `npm test -- logger` subset or full `npm test` if feasible. - Final `git status` check for cleanliness. diff --git a/spec/open-issues-triage.md b/spec/open-issues-triage.md deleted file mode 100644 index 869d84c..0000000 --- a/spec/open-issues-triage.md +++ /dev/null @@ -1,187 +0,0 @@ -# Open Issues Triage Analysis - -**Date**: 2025-11-19 -**Repository**: open-hax/codex -**Total Open Issues**: 10 - -## Proposed Labels - -### Topic Labels - -- `authentication` - OAuth, token management, cache file conflicts -- `session-management` - SessionManager, prompt cache keys, fork handling -- `compaction` - Conversation compaction, summary handling -- `model-support` - New model variants, normalization -- `metrics` - Request inspection, performance metrics -- `documentation` - README updates, package naming - -### Priority Labels - -- `priority-high` - Breaking bugs, critical functionality -- `priority-medium` - Important features, significant improvements -- `priority-low` - Minor enhancements, documentation fixes - -### Effort Labels - -- `effort-small` - < 4 hours, simple changes -- `effort-medium` - 4-12 hours, moderate complexity -- `effort-large` - > 12 hours, complex implementation - ---- - -## Issue Triage Details - -### #26: Feature: Add support for GPT-5.1-Codex-Max model - -**Labels**: `model-support`, `priority-medium`, `effort-small` -**Related Files**: - -- `lib/request/request-transformer.ts:217-244` - Model normalization logic -- `test/request-transformer.test.ts:50-120` - Model normalization tests - -### #25: [BUG] Plugin fails with confusing errors if started with the other oauth plugin's cache files - -**Labels**: `authentication`, `priority-high`, `effort-medium` -**Related Files**: - -- `lib/auth/auth.ts:31-69` - Token validation and refresh logic -- `lib/cache/session-cache.ts` - Cache file handling -- `lib/prompts/codex.ts:79-146` - Cache file operations - -### #24: Tests: clarify extractTailAfterSummary semantics in codex-compaction - -**Labels**: `compaction`, `priority-low`, `effort-small` -**Related Files**: - -- `lib/compaction/codex-compaction.ts:119` - extractTailAfterSummary function -- `test/codex-compaction.test.ts:86-93` - Related tests - -### #23: SessionManager: align fork identifier with prompt cache fork hints - -**Labels**: `session-management`, `priority-medium`, `effort-medium` -**Related Files**: - -- `lib/session/session-manager.ts:139-395` - SessionManager implementation -- `lib/request/request-transformer.ts:755-925` - Fork handling and cache key logic -- `test/session-manager.test.ts:161-181` - Fork session tests - -### #22: Compaction heuristics: prefer explicit metadata flag for OpenCode prompts - -**Labels**: `compaction`, `priority-medium`, `effort-medium` -**Related Files**: - -- `lib/request/request-transformer.ts:442-506` - OpenCode prompt filtering -- `lib/compaction/codex-compaction.ts` - Compaction logic -- `test/request-transformer.test.ts:596-624` - Compaction integration tests - -### #21: Compaction: make extractTailAfterSummary summary-aware - -**Labels**: `compaction`, `priority-medium`, `effort-medium` -**Related Files**: - -- `lib/compaction/codex-compaction.ts:119` - Core function -- `lib/compaction/compaction-executor.ts:1-45` - Compaction execution -- `test/codex-compaction.test.ts:86-93` - Function tests - -### #6: Feature: richer Codex metrics and request inspection commands - -**Labels**: `metrics`, `priority-medium`, `effort-large` -**Related Files**: - -- `lib/commands/codex-metrics.ts:1-343` - Metrics command implementation -- `lib/cache/cache-metrics.ts` - Cache metrics collection -- `test/codex-metrics-command.test.ts:1-342` - Comprehensive tests - -### #5: Feature: Codex-style conversation compaction and auto-compaction in plugin - -**Labels**: `compaction`, `priority-high`, `effort-large` -**Related Files**: - -- `lib/compaction/compaction-executor.ts:1-45` - Auto-compaction logic -- `lib/request/fetch-helpers.ts:120-185` - Compaction integration -- `lib/session/session-manager.ts:296-313` - Compaction state management -- `test/compaction-executor.test.ts:11-131` - Compaction tests - -### #4: Feature: fork-aware prompt_cache_key handling and overrides - -**Labels**: `session-management`, `priority-high`, `effort-large` -**Related Files**: - -- `lib/request/request-transformer.ts:755-1036` - Fork-aware cache key logic -- `lib/session/session-manager.ts:83-206` - Session ID derivation -- `test/request-transformer.test.ts:715-850` - Cache key tests -- `test/session-manager.test.ts:161-181` - Fork session tests - -### #11: Docs: Fix package name in test/README.md - -**Labels**: `documentation`, `priority-low`, `effort-small` -**Related Files**: - -- `test/README.md:1-4` - Package name reference - ---- - -## Priority Summary - -### High Priority (3 issues) - -- #25: OAuth cache file conflicts (bug) -- #5: Auto-compaction implementation (feature) -- #4: Fork-aware cache keys (feature) - -### Medium Priority (5 issues) - -- #26: GPT-5.1-Codex-Max support (feature) -- #23: SessionManager fork alignment (feature) -- #22: Compaction metadata flags (feature) -- #21: Summary-aware compaction (feature) -- #6: Enhanced metrics (feature) - -### Low Priority (2 issues) - -- #24: Test clarification (maintenance) -- #11: Documentation fix (maintenance) - -## Effort Distribution - -### Large Effort (>12 hours): 3 issues - -- #6: Enhanced metrics and inspection -- #5: Auto-compaction implementation -- #4: Fork-aware cache key handling - -### Medium Effort (4-12 hours): 5 issues - -- #25: OAuth cache file conflicts -- #23: SessionManager fork alignment -- #22: Compaction metadata flags -- #21: Summary-aware compaction -- #26: GPT-5.1-Codex-Max support - -### Small Effort (<4 hours): 2 issues - -- #24: Test clarification -- #11: Documentation fix - -## Topic Distribution - -- Session Management: 2 issues (#4, #23) -- Compaction: 4 issues (#5, #21, #22, #24) -- Authentication: 1 issue (#25) -- Model Support: 1 issue (#26) -- Metrics: 1 issue (#6) -- Documentation: 1 issue (#11) - -## Recommendations - -1. **Immediate Focus**: Address #25 (OAuth cache conflicts) as it's a breaking bug -2. **Strategic Features**: Prioritize #4 and #5 for core functionality improvements -3. **Quick Wins**: Complete #11 and #24 for immediate closure -4. **Incremental Development**: #21, #22, #23 can be tackled in sequence as they're related -5. **Future Enhancement**: #6 and #26 can be scheduled for future releases - -## Cross-Dependencies - -- #4 (fork-aware cache keys) enables #23 (SessionManager alignment) -- #21 and #22 both enhance compaction heuristics and should be coordinated -- #5 depends on improvements from #21 and #22 for optimal implementation diff --git a/spec/plugin-log-settings-doc.md b/spec/plugin-log-settings-doc.md new file mode 100644 index 0000000..cfe719b --- /dev/null +++ b/spec/plugin-log-settings-doc.md @@ -0,0 +1,24 @@ +# Plugin log settings docs update + +## Goal + +Add logging settings to the README Plugin-Level Settings section so users see rolling log controls alongside existing plugin config options. + +## References + +- README.md: Plugin-Level Settings section starts at ~49-63. +- docs/configuration.md: Plugin Configuration and Log file management at ~373-420. +- spec/environment-variables.md: notes logging env vars overrideable via ~/.opencode/openhax-codex-config.json (~43). + +## Definition of Done + +- README Plugin-Level Settings enumerates logging controls (max bytes/files/queue) available via plugin configuration/environment variables. +- Example updated/expanded to show logging block usage. +- Documentation consistent with docs/configuration.md values and defaults. +- No broken markdown formatting. + +## Plan + +- Phase 1: Align on messaging by pulling log setting names/defaults from docs/configuration.md. +- Phase 2: Update README Plugin-Level Settings bullet list and example to include logging settings. +- Phase 3: Self-review for clarity/consistency; no tests needed (docs-only). diff --git a/spec/pr-2-conflict-analysis.md b/spec/pr-2-conflict-analysis.md deleted file mode 100644 index 040c6e1..0000000 --- a/spec/pr-2-conflict-analysis.md +++ /dev/null @@ -1,24 +0,0 @@ -# PR #2 Conflict Analysis - -## Context -- Local work was done on `feature/review-automation`, then `git fetch && git merge main` was executed from that branch. -- `main` in the local worktree had not been updated since before commit `f3dd0e160cddbd2f08aa4294bd5b007d6b79d18b` ("Automate CI and review workflows"), so merging it brought in no new changes. -- `git checkout main` now shows `Your branch is behind 'origin/main' by 1 commit`, confirming that the local `main` is stale relative to `origin/main`. -- PR #2 (`bug-fix/compaction` → `main`) must merge into `origin/main`, which already contains the CI automation changes above; because `feature/review-automation` has not incorporated that commit, GitHub still flags conflicts. - -## Code References -- `.github/workflows/ci.yml:1` – workflow rewritten in commit `f3dd0e1`; PR #2 still has the previous structure. -- `scripts/detect-release-type.mjs:1` – new script created in the same commit that the feature branch is missing. -- `pnpm-lock.yaml:1` – lockfile introduced in `origin/main`; branch still tracks the removed `bun.lock` / `package-lock.json`, so GitHub reports conflicts in those files. - -## Existing Issues / PRs -- PR #2 "this is a thing" (head: `bug-fix/compaction`, base: `main`). - -## Definition of Done -- Explain why GitHub reports conflicts even though `git merge main` on the feature branch says "Already up to date". -- Provide concrete steps to sync the branch with the true base (`origin/main`) so that the PR no longer conflicts. - -## Requirements -1. Update local `main` with `git checkout main && git pull --ff-only origin main`. -2. Rebase or merge `origin/main` into `feature/review-automation` (or `bug-fix/compaction`, depending on the PR head) so that commit `f3dd0e1` and its files are present locally. -3. Resolve resulting conflicts locally (expect them in `.github/workflows/ci.yml`, `package-lock.json`, `.gitignore`, etc.), run tests, and push the updated branch. diff --git a/spec/pr-20-review.md b/spec/pr-20-review.md deleted file mode 100644 index 03eaa24..0000000 --- a/spec/pr-20-review.md +++ /dev/null @@ -1,28 +0,0 @@ -# PR 20 Review Tracking - -## Code files referenced - -- `test/plugin-config.test.ts:45-124` – validate that the two error-handling tests are de-duplicated, single `consoleSpy` call is scoped, and asserts match the extended default config shape (`enableCodexCompaction`, `autoCompactMinMessages`). -- `lib/request/fetch-helpers.ts:136-155` – ensure `applyCompactedHistory` is guarded by `compactionEnabled` and does not run when `pluginConfig.enableCodexCompaction === false`. -- `lib/request/request-transformer.ts:71-83` – keep `computeFallbackHashForBody` resilient to non-serializable metadata by wrapping the stringification in a `try/catch` and falling back to a stable seed (e.g., the normalized model name). -- `lib/request/request-transformer.ts:560-665` – preserve the compaction prompt sanitization heuristics while watching for future false positives (optional follow up). - -## Existing issues - -- `https://github.com/open-hax/codex/pull/20` (device/stealth) has open review comments from coderabbit.ai about the plugin-config tests, compaction gating, and hashing robustness. The `coderabbit` review thread `PRR_kwDOQJmo4M7O5BH7` is marked as TODO. - -## Existing PRs referenced - -- `https://github.com/open-hax/codex/pull/20` - -## Definition of done - -1. All actionable review comments on PR #20 are resolved (tests updated, compaction gating fixed, fallback hashing hardened, or noted as intentional). -2. `npm test` (or equivalent targeted regex) passes locally, proving the test suite is consistent with the new expectations. -3. The spec and summary explain which comments were addressed and why. - -## Requirements - -- Stick to the Codex CLI roadmap (no new features beyond review fixes). -- Do not revert or discard unrelated branch changes minted earlier in `device/stealth`. -- Maintain lint/format output (current `pnpm lint` steps already run by CI). Keep new tests minimal. diff --git a/spec/pr-29-review-analysis.md b/spec/pr-29-review-analysis.md index ddfb23c..4f0be0e 100644 --- a/spec/pr-29-review-analysis.md +++ b/spec/pr-29-review-analysis.md @@ -26,15 +26,12 @@ PR #29 has **1 unresolved review thread** from `coderabbitai` containing **19 ac 4. **Fix Mock Leakage** - `test/index.test.ts:22-28, 93-121` - Reset `sessionManager` instance mocks in `beforeEach` to prevent cross-test leakage -5. **Add Missing Test Case** - `test/codex-fetcher.test.ts` - - Add direct `compactionDecision` test case coverage - -6. **Fix Redundant Tests** - `test/codex-fetcher.test.ts:272-287` +5. **Fix Redundant Tests** - `test/codex-fetcher.test.ts:272-287` - Either provide distinct inputs for short/long text scenarios or remove redundant test ### 🔧 **Code Quality Improvements** -7. **Logger Hardening** - `lib/logger.ts:138-159` +6. **Logger Hardening** - `lib/logger.ts:138-159` - Add try/catch around `JSON.stringify(extra)` to prevent logging failures - Remove unused `error` parameter from `logToConsole` diff --git a/spec/pr-commit-2025-11-21.md b/spec/pr-commit-2025-11-21.md new file mode 100644 index 0000000..153f403 --- /dev/null +++ b/spec/pr-commit-2025-11-21.md @@ -0,0 +1,32 @@ +# Commit & PR Plan (2025-11-21) + +## Summary + +- Prepare commit and PR for current dev branch changes: logging controls, session cache key handling, prompt filtering, and removal of legacy compaction artifacts plus related docs updates. + +## Code Files & Line References + +- lib/config.ts:12-18,52-59 – Default config now includes logging toggles (warning toasts, console mirroring) merged with user config. +- lib/logger.ts:12-34,84-113,175-218,231-289 – Environment-driven logging defaults, overrides via plugin config, toast/console behaviors, and wrap logic to avoid truncation. +- lib/request/input-filters.ts:18-48,72-189 – Input sanitization, OpenCode system prompt filtering, bridge prompt injection with caching/continuity. +- lib/session/session-manager.ts:18-203,232-480 – Session cache key derivation (conversation/fork), prefix mismatch handling with reset or fork, prompt cache key reuse, and metrics. +- lib/types.ts:6-41,91-110 – Plugin/logging config shape and request/session typings. +- test/logger.test.ts:67-223 – Coverage for env vs config logging, warning toasts/console mirroring, rotation/queue overflow, and test-only console behavior. +- test/request-transformer.test.ts:121-1200 – Extensive cases for model normalization, prompt cache keys, bridge vs tool remap, ID stripping, include fields, and fallback logging expectations. +- test/session-manager.test.ts:48-189 – Session reuse, prefix mismatch warnings (system change/history prune), cache key regeneration, forks, and metrics/eviction behaviors. +- Removed: lib/compaction/\*.ts, lib/prompts/codex-compaction.ts, lib/request/compaction-helpers.ts, spec/auto-compaction-summary.md, spec/remove-plugin-compaction.md, spec/issue-triage-2025-11-20.md, spec/open-issues-triage.md, spec/pr-2-conflict-analysis.md, spec/pr-20-review.md, spec/review-pr-20-plan.md. + +## Existing Issues / PRs + +- None identified from history; last main commit a455bd1 "chore: release v0.4.3". Proceed on current dev branch. + +## Requirements + +- User request: commit all current changes and open a new PR from dev branch. +- Preserve user-authored changes; do not revert. + +## Definition of Done + +- All listed changes staged and committed on dev branch. +- `npm test` succeeds (or any failures documented). +- New PR opened targeting main with summary of logging/session/prompt updates and compaction removal. diff --git a/spec/readme-cleanup.md b/spec/readme-cleanup.md index a95f7ce..4ae4745 100644 --- a/spec/readme-cleanup.md +++ b/spec/readme-cleanup.md @@ -43,5 +43,5 @@ - 2025-11-21: Added Installation section, renamed Configuration Reference, removed standalone requirements block, moved TOS near bottom, and updated related anchors in docs/config README files. - 2025-11-21: Promoted minimal provider config (plugin array + single `openai/gpt-5.1-codex-max` model with provider/openai options) to top of Installation and Configuration Reference. - 2025-11-21: Removed non-functional Built-in Codex Commands section pending upstream support. -- 2025-11-21: Surfaced plugin-level settings (codexMode, caching, compaction) immediately after Installation with example JSON. +- 2025-11-21: Surfaced plugin-level settings (codexMode, caching) immediately after Installation with example JSON. - 2025-11-21: Removed duplicated plugin-level settings block from Configuration Reference; now it links back to the top settings section. diff --git a/spec/remove-plugin-compaction.md b/spec/remove-plugin-compaction.md deleted file mode 100644 index dfc7ff9..0000000 --- a/spec/remove-plugin-compaction.md +++ /dev/null @@ -1,30 +0,0 @@ -# Remove plugin compaction - -## Scope - -Remove Codex plugin-specific compaction (manual + auto) so compaction is left to OpenCode or other layers. - -## Code refs (entry points) - -- lib/request/fetch-helpers.ts: compaction settings, detectCompactionCommand, pass compaction options to transform, track compactionDecision. -- lib/request/request-transformer.ts: applyCompactionIfNeeded, skip transforms when compactionDecision present. -- lib/request/compaction-helpers.ts: builds compaction prompt and decision logic. -- lib/compaction/codex-compaction.ts and lib/prompts/codex-compaction.ts: prompt content and helpers (detect command, approximate tokens, build summary). -- lib/compaction/compaction-executor.ts: rewrites responses and stores summaries. -- lib/session/session-manager.ts: applyCompactionSummary/applyCompactedHistory state injections. -- lib/request/input-filters.ts: compaction heuristics and metadata flags. -- lib/types.ts: plugin config fields for compaction. -- lib/request/codex-fetcher.ts: finalizeCompactionResponse usage. -- Tests: compaction-executor.test.ts, codex-compaction.test.ts, compaction-helpers.test.ts, codex-fetcher.test.ts, fetch-helpers.test.ts (compaction section), request-transformer.test.ts (compaction metadata), session-manager.test.ts (compaction state), docs README/configuration/getting-started. - -## Definition of done - -- Plugin no longer performs or triggers compaction (manual/auto) in request/response flow. -- Plugin config no longer exposes compaction knobs, docs updated accordingly. -- Tests updated/removed to reflect lack of plugin compaction. - -## Requirements - -- Preserve prompt caching/session behavior unrelated to compaction. -- Avoid breaking tool/transform flow; codex bridge still applied. -- Keep code ASCII and minimal surgical changes. diff --git a/spec/request-transformer-refactor.md b/spec/request-transformer-refactor.md index 4c5eefc..c2767ec 100644 --- a/spec/request-transformer-refactor.md +++ b/spec/request-transformer-refactor.md @@ -8,7 +8,7 @@ ## Relevant Code References -- `lib/request/request-transformer.ts` lines 1-1094: monolithic helpers for model normalization, reasoning config, input filtering, bridge/tool messages, compaction, prompt cache keys, and `transformRequestBody` entrypoint. +- `lib/request/request-transformer.ts` lines 1-1094: monolithic helpers for model normalization, reasoning config, input filtering, bridge/tool messages, prompt cache keys, and `transformRequestBody` entrypoint. - `lib/request/tool-normalizer.ts` lines 1-158: provides `normalizeToolsForResponses` used by transformer but not imported. - Tests mirror structure under `test/` (e.g., `test/request-transformer.test.ts`). @@ -23,8 +23,8 @@ ### Phase 1: Extraction Design -- Identify logical groupings (model/reasoning config, input filtering/bridge, compaction helpers, prompt cache key utilities, tool normalization usage, main transform orchestration). -- Decide target helper modules under `lib/request/` to move into (e.g., `model-config.ts`, `input-filters.ts`, `prompt-cache.ts`, `compaction-helpers.ts`). +- Identify logical groupings (model/reasoning config, input filtering/bridge, prompt cache key utilities, tool normalization usage, main transform orchestration). +- Decide target helper modules under `lib/request/` to move into (e.g., `model-config.ts`, `input-filters.ts`, `prompt-cache.ts`). ### Phase 2: Implement Refactors @@ -39,11 +39,11 @@ ## Notes -- Preserve existing behavior (stateless filtering, bridge prompt caching, compaction decisions, prompt cache key derivation). +- Preserve existing behavior (stateless filtering, bridge prompt caching, prompt cache key derivation). - Avoid altering public APIs consumed by tests unless necessary; adjust tests if import paths change. ## Change Log -- Split `lib/request/request-transformer.ts` into helper modules (`model-config.ts`, `input-filters.ts`, `prompt-cache.ts`, `compaction-helpers.ts`, `tooling.ts`) and re-exported APIs to keep the transformer under 500 lines. +- Split `lib/request/request-transformer.ts` into helper modules (`model-config.ts`, `input-filters.ts`, `prompt-cache.ts`, `tooling.ts`) and re-exported APIs to keep the transformer under 500 lines. - Added missing `normalizeToolsForResponses` import via `normalizeToolsForCodexBody` helper. - Ran `pnpm build` and `pnpm lint` (lint only warning remains about legacy `.eslintignore`). diff --git a/spec/review-pr-20-plan.md b/spec/review-pr-20-plan.md deleted file mode 100644 index 12c352f..0000000 --- a/spec/review-pr-20-plan.md +++ /dev/null @@ -1,28 +0,0 @@ -# Review Plan for PR #20 (Device/stealth) - -## Overview -- Address coderabbitai's remaining comments on https://github.com/open-hax/codex/pull/20 before merging. -- Focus on fixing the failing `test/plugin-config.test.ts` assertions and strengthening compaction-related logic. - -## Target files and lines -1. `test/plugin-config.test.ts` (≈90‑140): Remove duplicate `it('should handle file read errors gracefully')`, keep a single error-handling test that asserts the current `PluginConfig` defaults (`codexMode`, `enablePromptCaching`, `enableCodexCompaction`, `autoCompactMinMessages`) and verifies warning logging. -2. `lib/request/fetch-helpers.ts` (≈34‑55): Guard `sessionManager?.applyCompactedHistory` behind `compactionEnabled` so `enableCodexCompaction = false` truly disables history reuse. -3. `lib/request/request-transformer.ts` (≈896‑977): Wrap `computeFallbackHashForBody` serialization in `try/catch` and fall back to hashing just the `model` string when metadata is not JSON-safe. - -## Existing references -- Open PR: open-hax/codex#20 (Device/stealth branch). Coderabbitai submitted reviews on commits f56e506e0f07… and 8757e76457dc… with blockers noted above. -- No upstream GitHub issues are cited; the actionable items come solely from the reviewer’s comments. - -## Definition of done -1. `test/plugin-config.test.ts` compiles, contains no duplicate `it` names, and asserts the current default config (includes `enableCodexCompaction` and `autoCompactMinMessages`), logging expectations remain within the test body. -2. `transformRequestForCodex` only applies compacted history when `pluginConfig.enableCodexCompaction !== false` (in addition to the existing manual command guard). -3. `computeFallbackHashForBody` no longer throws when metadata/input contain non-serializable values; it falls back to hashing a stable string (e.g., `model`). -4. Documented plan is shared in PR comment before implementing code. -5. Tests covering touched files pass locally (at least the relevant suites). -6. Changes committed, pushed, and the reviewer notified via response. - -## Requirements -- Must respond on PR with the plan before coding begins. -- Keep existing tests (plugin config, fetch helpers, session manager) green after modifications. -- Preserve logging expectations in relevant tests (use spies to verify warnings in failure cases). -- Push updates to the same branch once changes and tests are complete. diff --git a/spec/review-v0.3.5-fixes.md b/spec/review-v0.3.5-fixes.md index a2ba497..e416fa3 100644 --- a/spec/review-v0.3.5-fixes.md +++ b/spec/review-v0.3.5-fixes.md @@ -3,7 +3,6 @@ ## Scope - Handle null/empty cache reads in `lib/prompts/codex.ts` around readCachedInstructions caching logic -- Remove redundant cloning in `lib/request/compaction-helpers.ts` (removeLastUserMessage, maybeBuildCompactionPrompt) - Prevent duplicate tool remap injection in `lib/request/input-filters.ts` addToolRemapMessage ## Existing issues / PRs @@ -13,7 +12,6 @@ ## Definition of done - safeReadFile null results do not get cached as empty content; fallback logic remains available for caller -- Compaction helpers avoid unnecessary clones while preserving immutability semantics (original input reused unless truncated) - Tool remap message is only prepended once when tools are present; logic handles undefined/null safely - All relevant tests updated or added if behavior changes; existing suite passes locally if run diff --git a/spec/session-prefix-mismatch.md b/spec/session-prefix-mismatch.md index b4176f8..1b18c68 100644 --- a/spec/session-prefix-mismatch.md +++ b/spec/session-prefix-mismatch.md @@ -1,23 +1,28 @@ # Session cache prefix mismatch – bridge injection ## Context + - Repeated log: `SessionManager: prefix mismatch detected, regenerating cache key` (e.g., sessionId `ses_5610847c3ffey8KLQaUCsUdtks`) now appears beyond the first turn, implying cache keys reset every request. - Suspect flow: `addCodexBridgeMessage` skips reinjection when `sessionContext.state.bridgeInjected` is true, so turn 1 includes the bridge, turn 2 omits it; SessionManager compares the prior bridged input to the new unbridged input and treats it as a prefix mismatch. ## Code links + - `lib/session/session-manager.ts:248-299` — prefix check and regeneration path (`sharesPrefix`, `applyRequest`). - `lib/request/request-transformer.ts:612-657` — bridge injection with session-scoped skip flag. - `lib/request/fetch-helpers.ts:119-205` — session context retrieval + transform + `applyRequest` ordering. ## Existing issues / PRs + - None found specific to this regression (branch: `chore/codex-max-release-review`). ## Definition of done + - Bridge/system prompt handling keeps the input prefix stable across sequential tool turns; no repeated prefix-mismatch warnings after the first turn of a conversation. - `prompt_cache_key` remains stable across multi-turn sessions unless the history genuinely diverges. - Automated tests cover a multi-turn tool conversation to ensure bridge injection does not trigger SessionManager resets. ## Requirements + - Add a regression test demonstrating stable caching across consecutive turns with the bridge prompt injected. - Adjust bridge injection or prefix handling so SessionManager sees a consistent prefix across turns. -- Keep existing behavior for compaction and tool normalization intact; avoid altering host-provided prompt_cache_key semantics. +- Keep existing behavior for tool normalization intact; avoid altering host-provided prompt_cache_key semantics. diff --git a/test/logger.test.ts b/test/logger.test.ts index 9929631..4736a96 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -142,14 +142,19 @@ describe("logger", () => { expect(logSpy).not.toHaveBeenCalled(); }); - it("logWarn emits to console even without env overrides", async () => { + it("logWarn writes to rolling log but stays off console by default", async () => { fsMocks.existsSync.mockReturnValue(true); const { logWarn, flushRollingLogsForTest } = await import("../lib/logger.js"); logWarn("warning"); await flushRollingLogsForTest(); - expect(warnSpy).toHaveBeenCalledWith("[openhax/codex] warning"); + expect(warnSpy).not.toHaveBeenCalled(); + expect(fsMocks.appendFile).toHaveBeenCalledTimes(1); + const [logPath, logLine, logEncoding] = fsMocks.appendFile.mock.calls[0]; + expect(logPath).toBe("/mock-home/.opencode/logs/codex-plugin/codex-plugin.log"); + expect(logEncoding).toBe("utf8"); + expect(logLine as string).toContain('"message":"warning"'); }); it("logWarn does not send warning toasts by default even when tui is available", async () => { @@ -170,7 +175,8 @@ describe("logger", () => { expect(showToast).not.toHaveBeenCalled(); expect(appLog).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith("[openhax/codex] toast-warning"); + expect(warnSpy).not.toHaveBeenCalled(); + expect(fsMocks.appendFile).toHaveBeenCalledTimes(1); }); it("logWarn sends warning toasts only when enabled via config", async () => { @@ -200,6 +206,19 @@ describe("logger", () => { expect(warnSpy).not.toHaveBeenCalled(); }); + it("logWarn mirrors to console when enabled via config", async () => { + fsMocks.existsSync.mockReturnValue(true); + const { configureLogger, logWarn, flushRollingLogsForTest } = await import("../lib/logger.js"); + + configureLogger({ pluginConfig: { logging: { logWarningsToConsole: true } } }); + + logWarn("console-warning"); + await flushRollingLogsForTest(); + + expect(warnSpy).toHaveBeenCalledWith("[openhax/codex] console-warning"); + expect(fsMocks.appendFile).toHaveBeenCalled(); + }); + it("wraps long toast messages to avoid truncation", async () => { fsMocks.existsSync.mockReturnValue(true); const showToast = vi.fn(); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index f213f47..513b98e 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -52,7 +52,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - logging: { showWarningToasts: false }, + logging: { showWarningToasts: false, logWarningsToConsole: false }, }); expect(mockExistsSync).toHaveBeenCalledWith( @@ -69,7 +69,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: false, enablePromptCaching: true, - logging: { showWarningToasts: false }, + logging: { showWarningToasts: false, logWarningsToConsole: false }, }); }); @@ -82,7 +82,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - logging: { showWarningToasts: false }, + logging: { showWarningToasts: false, logWarningsToConsole: false }, }); }); @@ -98,6 +98,7 @@ describe("Plugin Configuration", () => { enableRequestLogging: false, logMaxFiles: 2, showWarningToasts: false, + logWarningsToConsole: false, }); }); @@ -111,8 +112,9 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - logging: { showWarningToasts: false }, - }); + logging: { showWarningToasts: false, logWarningsToConsole: false }, + }); + expect(logWarnSpy).toHaveBeenCalled(); logWarnSpy.mockRestore(); }); @@ -129,7 +131,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - logging: { showWarningToasts: false }, + logging: { showWarningToasts: false, logWarningsToConsole: false }, }); expect(logWarnSpy).toHaveBeenCalled(); logWarnSpy.mockRestore(); diff --git a/test/prompts-codex.test.ts b/test/prompts-codex.test.ts index fbfccef..b5babdf 100644 --- a/test/prompts-codex.test.ts +++ b/test/prompts-codex.test.ts @@ -112,7 +112,8 @@ describe("Codex Instructions Fetcher", () => { it("falls back to cached instructions when fetch fails", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const logger = await import("../lib/logger.js"); + const logWarnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {}); const previousLastChecked = Date.now() - 20 * 60 * 1000; files.set(cacheFile, "still-good"); files.set( @@ -140,9 +141,7 @@ describe("Codex Instructions Fetcher", () => { expect(consoleError).toHaveBeenCalledWith( '[openhax/codex] Failed to fetch instructions from GitHub {"error":"HTTP 500 fetching https://raw.githubusercontent.com/openai/codex/v2/codex-rs/core/gpt_5_codex_prompt.md"}', ); - expect(consoleWarn).toHaveBeenCalledWith( - "[openhax/codex] Using cached instructions due to fetch failure", - ); + expect(logWarnSpy).toHaveBeenCalledWith("Using cached instructions due to fetch failure"); const meta = JSON.parse(files.get(cacheMeta) ?? "{}"); expect(meta.lastChecked).toBeGreaterThan(previousLastChecked); @@ -150,7 +149,7 @@ describe("Codex Instructions Fetcher", () => { expect(meta.url).toContain("codex-rs/core/gpt_5_codex_prompt.md"); consoleError.mockRestore(); - consoleWarn.mockRestore(); + logWarnSpy.mockRestore(); }); it("serves in-memory session cache when latest entry exists", async () => { @@ -242,7 +241,8 @@ describe("Codex Instructions Fetcher", () => { it("falls back to bundled instructions when no cache is available", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const logger = await import("../lib/logger.js"); + const logWarnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {}); fetchMock .mockResolvedValueOnce( @@ -260,7 +260,7 @@ describe("Codex Instructions Fetcher", () => { expect(consoleError).toHaveBeenCalledWith( '[openhax/codex] Failed to fetch instructions from GitHub {"error":"HTTP 500 fetching https://raw.githubusercontent.com/openai/codex/v1/codex-rs/core/gpt_5_codex_prompt.md"}', ); - expect(consoleWarn).toHaveBeenCalledWith("[openhax/codex] Falling back to bundled instructions"); + expect(logWarnSpy).toHaveBeenCalledWith("Falling back to bundled instructions"); const meta = JSON.parse(files.get(cacheMeta) ?? "{}"); expect(meta.tag).toBe("v1"); @@ -268,6 +268,6 @@ describe("Codex Instructions Fetcher", () => { expect(meta.url).toContain("codex-rs/core/gpt_5_codex_prompt.md"); consoleError.mockRestore(); - consoleWarn.mockRestore(); + logWarnSpy.mockRestore(); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index bdc733d..1a70029 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -245,8 +245,8 @@ describe("filterInput", () => { id: "msg_456", type: "message", role: "developer", - content: "Summary saved to ~/.opencode/summary.md", - metadata: { source: "opencode-compaction" }, + content: "Custom host metadata message", + metadata: { source: "host-metadata" }, }, ]; const result = filterInput(input, { preserveMetadata: true }); @@ -631,21 +631,6 @@ describe("filterOpenCodeSystemPrompts", () => { expect(result![1].role).toBe("user"); }); - it("should use metadata flag to detect compaction prompts", async () => { - const input: InputItem[] = [ - { - type: "message", - role: "developer", - content: "Summary saved to ~/.opencode/summary.md for inspection", - metadata: { source: "opencode-compaction" }, - }, - { type: "message", role: "user", content: "continue" }, - ]; - const result = await filterOpenCodeSystemPrompts(input); - expect(result).toHaveLength(1); - expect(result![0].role).toBe("user"); - }); - it("should return undefined for undefined input", async () => { expect(await filterOpenCodeSystemPrompts(undefined)).toBeUndefined(); }); @@ -808,29 +793,6 @@ describe("transformRequestBody", () => { expect(result2.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); }); - it("filters metadata-tagged compaction prompts and strips metadata when IDs are not preserved", async () => { - const body: RequestBody = { - model: "gpt-5", - input: [ - { - type: "message", - role: "developer", - content: "Summary saved to ~/.opencode/summary.md for inspection", - metadata: { source: "opencode-compaction" }, - }, - { type: "message", role: "user", content: "continue" }, - ], - }; - - const transformedBody = await transformRequestBody(body, codexInstructions); - expect(transformedBody).toBeDefined(); - const messages = transformedBody.input ?? []; - - expect(messages.some((item) => (item as any).metadata)).toBe(false); - expect(JSON.stringify(messages)).not.toContain(".opencode/summary"); - expect(messages.some((item) => item.role === "user" && (item as any).content === "continue")).toBe(true); - }); - it("keeps bridge prompt across turns so prompt_cache_key stays stable", async () => { const sessionManager = new SessionManager({ enabled: true }); const baseInput: InputItem[] = [ diff --git a/test/session-manager.test.ts b/test/session-manager.test.ts index fa6a820..e020086 100644 --- a/test/session-manager.test.ts +++ b/test/session-manager.test.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import { SESSION_CONFIG } from "../lib/constants.js"; import { SessionManager } from "../lib/session/session-manager.js"; @@ -37,10 +36,6 @@ function createBody(conversationId: string, inputCount = 1, options: BodyOptions }; } -function hashItems(items: InputItem[]): string { - return createHash("sha1").update(JSON.stringify(items)).digest("hex"); -} - describe("SessionManager", () => { it("returns undefined when disabled", () => { const manager = new SessionManager({ enabled: false }); @@ -193,45 +188,6 @@ describe("SessionManager", () => { warnSpy.mockRestore(); }); - it("forks session when prefix matches partially and reuses compaction state", () => { - const manager = new SessionManager({ enabled: true }); - const baseBody = createBody("conv-prefix-fork", 3); - - let baseContext = manager.getContext(baseBody) as SessionContext; - baseContext = manager.applyRequest(baseBody, baseContext) as SessionContext; - - const systemMessage: InputItem = { type: "message", role: "system", content: "env vars" }; - manager.applyCompactionSummary(baseContext, { - baseSystem: [systemMessage], - summary: "Base summary", - }); - - const branchBody = createBody("conv-prefix-fork", 3); - branchBody.input = [ - { type: "message", role: "user", id: "msg_1", content: "message-1" }, - { type: "message", role: "user", id: "msg_2", content: "message-2" }, - { type: "message", role: "assistant", id: "msg_3", content: "diverged" }, - ]; - - let branchContext = manager.getContext(branchBody) as SessionContext; - branchContext = manager.applyRequest(branchBody, branchContext) as SessionContext; - - const sharedPrefix = branchBody.input.slice(0, 2) as InputItem[]; - const expectedSuffix = hashItems(sharedPrefix).slice(0, 8); - expect(branchBody.prompt_cache_key).toBe(`conv-prefix-fork::prefix::${expectedSuffix}`); - expect(branchContext.state.promptCacheKey).toBe(`conv-prefix-fork::prefix::${expectedSuffix}`); - expect(branchContext.isNew).toBe(true); - - const followUp = createBody("conv-prefix-fork", 1); - followUp.input = [{ type: "message", role: "user", content: "follow-up" }]; - manager.applyCompactedHistory(followUp, branchContext); - - expect(followUp.input).toHaveLength(3); - expect(followUp.input?.[0].role).toBe("system"); - expect(followUp.input?.[1].content).toContain("Base summary"); - expect(followUp.input?.[2].content).toBe("follow-up"); - }); - it("records cached token usage from response payload", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-usage"); @@ -331,46 +287,6 @@ describe("SessionManager", () => { expect(snakeParentContext.state.promptCacheKey).toBe("conv-fork-parent::fork::parent-snake"); }); - it("scopes compaction summaries per fork session", () => { - const manager = new SessionManager({ enabled: true }); - const alphaBody = createBody("conv-fork-summary", 1, { forkId: "alpha" }); - let alphaContext = manager.getContext(alphaBody) as SessionContext; - alphaContext = manager.applyRequest(alphaBody, alphaContext) as SessionContext; - - const systemMessage: InputItem = { type: "message", role: "system", content: "env vars" }; - manager.applyCompactionSummary(alphaContext, { - baseSystem: [systemMessage], - summary: "Alpha summary", - }); - - const alphaNext = createBody("conv-fork-summary", 1, { forkId: "alpha" }); - alphaNext.input = [{ type: "message", role: "user", content: "alpha task" }]; - manager.applyCompactedHistory(alphaNext, alphaContext); - expect(alphaNext.input).toHaveLength(3); - expect(alphaNext.input?.[1].content).toContain("Alpha summary"); - - const betaBody = createBody("conv-fork-summary", 1, { forkId: "beta" }); - let betaContext = manager.getContext(betaBody) as SessionContext; - betaContext = manager.applyRequest(betaBody, betaContext) as SessionContext; - - const betaNext = createBody("conv-fork-summary", 1, { forkId: "beta" }); - betaNext.input = [{ type: "message", role: "user", content: "beta task" }]; - manager.applyCompactedHistory(betaNext, betaContext); - expect(betaNext.input).toHaveLength(1); - - manager.applyCompactionSummary(betaContext, { - baseSystem: [], - summary: "Beta summary", - }); - - const betaFollowUp = createBody("conv-fork-summary", 1, { forkId: "beta" }); - betaFollowUp.input = [{ type: "message", role: "user", content: "beta follow-up" }]; - manager.applyCompactedHistory(betaFollowUp, betaContext); - expect(betaFollowUp.input).toHaveLength(2); - expect(betaFollowUp.input?.[0].content).toContain("Beta summary"); - expect(betaFollowUp.input?.[1].content).toBe("beta follow-up"); - }); - it("evicts sessions that exceed idle TTL", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-expire"); @@ -399,27 +315,4 @@ describe("SessionManager", () => { expect(metrics.totalSessions).toBe(SESSION_CONFIG.MAX_ENTRIES); expect(metrics.recentSessions.length).toBeLessThanOrEqual(SESSION_CONFIG.MAX_ENTRIES); }); - - it("applies compacted history when summary stored", () => { - const manager = new SessionManager({ enabled: true }); - const body = createBody("conv-compaction"); - let context = manager.getContext(body) as SessionContext; - context = manager.applyRequest(body, context) as SessionContext; - - const systemMessage: InputItem = { type: "message", role: "system", content: "env" }; - manager.applyCompactionSummary(context, { - baseSystem: [systemMessage], - summary: "Auto-compaction summary", - }); - - const nextBody = createBody("conv-compaction"); - nextBody.input = [{ type: "message", role: "user", content: "new task" }]; - manager.applyCompactedHistory(nextBody, context); - - expect(nextBody.input).toHaveLength(3); - expect(nextBody.input?.[0].role).toBe("system"); - expect(nextBody.input?.[1].role).toBe("user"); - expect(nextBody.input?.[1].content).toContain("Auto-compaction summary"); - expect(nextBody.input?.[2].content).toBe("new task"); - }); }); From 42253036883a55bc2b08d8a4b1d3632cbddb2ea5 Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 18:48:45 -0600 Subject: [PATCH 11/17] Add env-tail cache option and log inspector --- .eslintignore | 7 -- .gitignore | 2 +- README.md | 7 +- eslint.config.mjs | 1 + lib/request/input-filters.ts | 71 +++++++++++++- lib/request/request-transformer.ts | 22 ++++- lib/session/session-manager.ts | 55 +++++------ scripts/inspect-codex-logs.mjs | 139 +++++++++++++++++++++++++++ test/cache-e2e.test.ts | 121 ++++++++++++++++++++++++ test/request-transformer.test.ts | 147 ++++++++++++++++++++++++++++- test/session-manager.test.ts | 95 ++++++++++--------- 11 files changed, 568 insertions(+), 99 deletions(-) delete mode 100644 .eslintignore create mode 100644 scripts/inspect-codex-logs.mjs create mode 100644 test/cache-e2e.test.ts diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index ca9bb03..0000000 --- a/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -dist/ -node_modules/ -coverage/ -.serena/ -assets/ -docs/ -spec/ diff --git a/.gitignore b/.gitignore index 6dbc763..6c7df89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ bun.lockb +.worktrees/ dist/ coverage/ @@ -17,4 +18,3 @@ tmp .worktrees/ .envrc .env - diff --git a/README.md b/README.md index 4e8615b..7490035 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Want to customize? Jump to [Configuration reference](#configuration-reference). ## Plugin-Level Settings -Set these in `~/.opencode/openhax-codex-config.json` (applies to all models): +Set these in `~/.opencode/openhax-codex-config.json` (applies to all models). Related env vars control runtime tweaks (e.g., request logging, env tail): - `codexMode` (default `true`): enable the Codex ↔ OpenCode bridge prompt and tool remapping - `enablePromptCaching` (default `true`): keep a stable `prompt_cache_key` so Codex can reuse cached prompts @@ -60,6 +60,8 @@ Set these in `~/.opencode/openhax-codex-config.json` (applies to all models): - `logMaxBytes` (default `5_242_880` bytes): rotate rolling log after this size - `logMaxFiles` (default `5`): rotated log files to retain (plus the active log) - `logQueueMax` (default `1000`): max buffered log entries before oldest entries drop +- Env tail (optional): set `CODEX_APPEND_ENV_CONTEXT=1` to reattach env/files context as a trailing developer message (stripped from system prompts to keep the prefix stable). Default is unset/0 (env/files removed for maximum cache stability). +- Log inspection helper: `node scripts/inspect-codex-logs.mjs [--dir ] [--limit N] [--id X] [--stage after-transform]` summarizes cached request logs (shows model, prompt_cache_key, roles, etc.). Example: @@ -96,6 +98,8 @@ Example: **Prompt caching is enabled by default** to optimize your token usage and reduce costs. +> Optional: `CODEX_APPEND_ENV_CONTEXT=1` keeps env/files context by reattaching it as a trailing developer message while preserving a stable prefix. Leave unset to maximize cache stability. + ### How Caching Works - **Enabled by default**: `enablePromptCaching: true` @@ -110,6 +114,7 @@ Example: - Keep the tree stable: ensure noisy/ephemeral dirs are ignored (e.g. `dist/`, `build/`, `.next/`, `coverage/`, `.cache/`, `logs/`, `tmp/`, `.turbo/`, `.vite/`, `.stryker-tmp/`, `artifacts/`, and similar). Put transient outputs under an ignored directory or `/tmp`. - Don’t thrash the workspace mid-session: large checkouts, mass file generation, or moving directories will change the ripgrep listing and force a cache miss. - Model/provider switches also change the system prompt (different base prompt), so avoid swapping models in the middle of a session if you want to reuse cache. +- Optional: set `CODEX_APPEND_ENV_CONTEXT=1` to reattach env/files at the end of the prompt instead of stripping them. This keeps the shared prefix stable (better cache reuse) while still sending env/files as a trailing developer message. Default is off (env/files stripped to maximize stability). ### Managing Caching diff --git a/eslint.config.mjs b/eslint.config.mjs index aa9a2be..e706f28 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,7 @@ export default [ "assets/**", "docs/**", "spec/**", + ".worktrees/**", ], }, { diff --git a/lib/request/input-filters.ts b/lib/request/input-filters.ts index d14fc55..55fc9bf 100644 --- a/lib/request/input-filters.ts +++ b/lib/request/input-filters.ts @@ -69,10 +69,44 @@ export function isOpenCodeSystemPrompt(item: InputItem, cachedPrompt: string | n return contentText.startsWith("You are a coding agent running in"); } -export async function filterOpenCodeSystemPrompts( +type FilterResult = { input?: InputItem[]; envSegments: string[] }; + +function stripOpenCodeEnvBlocks(contentText: string): { + text: string; + removed: boolean; + removedBlocks: string[]; +} { + let removed = false; + let sanitized = contentText; + const removedBlocks: string[] = []; + + // Remove the standard environment header OpenCode prepends before + const envHeaderPattern = /Here is some useful information about the environment you are running in:\s*/i; + const headerStripped = sanitized.replace(envHeaderPattern, ""); + if (headerStripped !== sanitized) { + removed = true; + sanitized = headerStripped; + } + + const patterns = [/[\s\S]*?<\/env>/g, /[\s\S]*?<\/files>/g]; + + for (const pattern of patterns) { + const matches = sanitized.match(pattern); + if (matches) { + removedBlocks.push(...matches); + removed = true; + sanitized = sanitized.replace(pattern, ""); + } + } + + return { text: sanitized.trim(), removed, removedBlocks }; +} + +async function filterOpenCodeSystemPromptsInternal( input: InputItem[] | undefined, -): Promise { - if (!Array.isArray(input)) return input; + options: { captureEnv?: boolean } = {}, +): Promise { + if (!Array.isArray(input)) return input ? { input, envSegments: [] } : undefined; let cachedPrompt: string | null = null; try { @@ -82,6 +116,7 @@ export async function filterOpenCodeSystemPrompts( } const filteredInput: InputItem[] = []; + const envSegments: string[] = []; for (const item of input) { if (item.role === "user") { filteredInput.push(item); @@ -92,10 +127,38 @@ export async function filterOpenCodeSystemPrompts( continue; } + const contentText = extractTextFromItem(item); + if (typeof contentText === "string" && contentText.length > 0) { + const { text, removed, removedBlocks } = stripOpenCodeEnvBlocks(contentText); + if (options.captureEnv && removedBlocks.length > 0) { + envSegments.push(...removedBlocks.map((block) => block.trim()).filter(Boolean)); + } + if (removed && text.length === 0) { + continue; + } + if (removed) { + filteredInput.push({ ...item, content: text }); + continue; + } + } + filteredInput.push(item); } - return filteredInput; + return { input: filteredInput, envSegments }; +} + +export async function filterOpenCodeSystemPrompts( + input: InputItem[] | undefined, +): Promise { + const result = await filterOpenCodeSystemPromptsInternal(input); + return result?.input; +} + +export async function filterOpenCodeSystemPromptsWithEnv( + input: InputItem[] | undefined, +): Promise { + return filterOpenCodeSystemPromptsInternal(input, { captureEnv: true }); } function analyzeBridgeRequirement( diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index d6c9f4e..f61f6b7 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -6,7 +6,9 @@ import { addToolRemapMessage, filterInput, filterOpenCodeSystemPrompts, + filterOpenCodeSystemPromptsWithEnv, } from "./input-filters.js"; + import { getModelConfig, getReasoningConfig, normalizeModel } from "./model-config.js"; import { ensurePromptCacheKey, logCacheKeyDecision } from "./prompt-cache.js"; import { normalizeToolsForCodexBody } from "./tooling.js"; @@ -60,7 +62,25 @@ async function transformInputForCodex( } if (codexMode) { - workingInput = await filterOpenCodeSystemPrompts(workingInput); + const appendEnvTail = process.env.CODEX_APPEND_ENV_CONTEXT === "1"; + if (appendEnvTail) { + const result = await filterOpenCodeSystemPromptsWithEnv(workingInput); + workingInput = result?.input; + if (result?.envSegments?.length) { + workingInput = workingInput || []; + workingInput = [ + ...(workingInput || []), + { + type: "message", + role: "developer", + content: result.envSegments.join("\n"), + }, + ]; + } + } else { + workingInput = await filterOpenCodeSystemPrompts(workingInput); + } + if (!preserveIds) { workingInput = filterInput(workingInput, { preserveIds }); } diff --git a/lib/session/session-manager.ts b/lib/session/session-manager.ts index 94991c1..c616d24 100644 --- a/lib/session/session-manager.ts +++ b/lib/session/session-manager.ts @@ -4,7 +4,6 @@ import { logDebug, logWarn } from "../logger.js"; import { PROMPT_CACHE_FORK_KEYS } from "../request/prompt-cache.js"; import type { CodexResponsePayload, InputItem, RequestBody, SessionContext, SessionState } from "../types.js"; import { cloneInputItems } from "../utils/clone.js"; -import { isAssistantMessage, isUserMessage } from "../utils/input-item-utils.js"; export interface SessionManagerOptions { enabled: boolean; @@ -20,37 +19,6 @@ function computeHash(items: InputItem[]): string { return createHash("sha1").update(JSON.stringify(items)).digest("hex"); } -function extractLatestUserSlice(items: InputItem[] | undefined): InputItem[] { - if (!Array.isArray(items) || items.length === 0) { - return []; - } - - let lastUserIndex = -1; - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (item && isUserMessage(item)) { - lastUserIndex = index; - break; - } - } - - if (lastUserIndex < 0) { - return []; - } - - const tail: InputItem[] = []; - for (let index = lastUserIndex; index < items.length; index += 1) { - const item = items[index]; - if (item && (isUserMessage(item) || isAssistantMessage(item))) { - tail.push(item); - } else { - break; - } - } - - return cloneInputItems(tail); -} - function longestSharedPrefixLength(previous: InputItem[], current: InputItem[]): number { if (previous.length === 0 || current.length === 0) { return 0; @@ -134,7 +102,7 @@ function findSuffixReuseStart(previous: InputItem[], current: InputItem[]): numb return start; } -type PrefixChangeCause = "system_prompt_changed" | "history_pruned" | "unknown"; +type PrefixChangeCause = "system_prompt_changed" | "history_pruned" | "user_message_changed" | "unknown"; type PrefixChangeAnalysis = { cause: PrefixChangeCause; @@ -179,6 +147,19 @@ function analyzePrefixChange( }; } + if (firstPrevious?.role === "user" && firstIncoming?.role === "user") { + return { + cause: "user_message_changed", + details: { + mismatchIndex: sharedPrefixLength, + previousFingerprint: fingerprintInputItem(firstPrevious), + incomingFingerprint: fingerprintInputItem(firstIncoming), + previousRole: firstPrevious.role, + incomingRole: firstIncoming.role, + }, + }; + } + return { cause: "unknown", details: { @@ -410,12 +391,16 @@ export class SessionManager { if (sharedPrefixLength === 0) { logWarn("SessionManager: prefix mismatch detected, regenerating cache key", { sessionId: state.id, + promptCacheKey: state.promptCacheKey, sharedPrefixLength, previousItems: state.lastInput.length, incomingItems: input.length, + previousHash: state.lastPrefixHash, + incomingHash: inputHash, prefixCause: prefixAnalysis.cause, ...prefixAnalysis.details, }); + const refreshed = this.resetSessionInternal(state.id, true); if (!refreshed) { return undefined; @@ -458,10 +443,14 @@ export class SessionManager { this.sessions.set(forkSessionId, forkState); logWarn("SessionManager: prefix mismatch detected, forking session", { sessionId: state.id, + promptCacheKey: state.promptCacheKey, forkSessionId, + forkPromptCacheKey, sharedPrefixLength, previousItems: state.lastInput.length, incomingItems: input.length, + previousHash: state.lastPrefixHash, + incomingHash: inputHash, prefixCause: prefixAnalysis.cause, ...prefixAnalysis.details, }); diff --git a/scripts/inspect-codex-logs.mjs b/scripts/inspect-codex-logs.mjs new file mode 100644 index 0000000..f5948ab --- /dev/null +++ b/scripts/inspect-codex-logs.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +const DEFAULT_DIR = path.join(os.homedir(), ".opencode", "logs", "codex-plugin"); + +function getArg(flag, fallback) { + const idx = process.argv.indexOf(flag); + if (idx === -1) return fallback; + const value = process.argv[idx + 1]; + if (!value || value.startsWith("-")) return true; + return value; +} + +function parseFilters() { + return { + dir: getArg("--dir", DEFAULT_DIR), + limit: Number(getArg("--limit", 10)) || 10, + id: getArg("--id", null), + stage: getArg("--stage", null), + }; +} + +function safeRoles(input) { + if (!Array.isArray(input)) return []; + const roles = new Set(); + for (const item of input) { + if (item && typeof item.role === "string" && item.role.trim()) { + roles.add(item.role.trim()); + } + } + return Array.from(roles); +} + +function summarizeStage(stage, data) { + const body = data.body || {}; + const model = data.model || data.normalizedModel || body.model || data.originalModel; + const promptCacheKey = body.prompt_cache_key || body.promptCacheKey; + const inputLength = Array.isArray(body.input) ? body.input.length : data.inputLength; + const roles = safeRoles(body.input); + const reasoning = body.reasoning || data.reasoning || {}; + const include = body.include || data.include; + return { + stage, + timestamp: data.timestamp, + model, + originalModel: data.originalModel, + promptCacheKey, + inputLength, + roles, + reasoning, + textVerbosity: body.text?.verbosity || data.textVerbosity, + include, + usage: data.usage, + }; +} + +async function readJson(filePath) { + const raw = await readFile(filePath, "utf8"); + return JSON.parse(raw); +} + +async function collectLogs(dir, stageFilter, idFilter, limit) { + const entries = await readdir(dir); + const pattern = /^request-(\d+)-(.+)\.json$/; + const requests = new Map(); + + for (const entry of entries) { + const match = entry.match(pattern); + if (!match) continue; + const [, idStr, stage] = match; + if (stageFilter && stage !== stageFilter) continue; + if (idFilter && idStr !== String(idFilter)) continue; + const id = Number(idStr); + const filePath = path.join(dir, entry); + const data = await readJson(filePath).catch(() => null); + if (!data) continue; + if (!requests.has(id)) { + requests.set(id, []); + } + requests.get(id).push({ stage, data, filePath }); + } + + const ids = Array.from(requests.keys()) + .sort((a, b) => b - a) + .slice(0, limit); + return ids.map((id) => ({ id, stages: requests.get(id) || [] })); +} + +function printSummary(requests) { + for (const { id, stages } of requests) { + console.log(`\n# Request ${id}`); + for (const { stage, data, filePath } of stages.sort((a, b) => a.stage.localeCompare(b.stage))) { + const summary = summarizeStage(stage, data); + console.log(`- stage: ${summary.stage} (${filePath})`); + console.log(` timestamp: ${summary.timestamp || "n/a"}`); + console.log( + ` model: ${summary.model || "n/a"}${summary.originalModel ? ` (orig ${summary.originalModel})` : ""}`, + ); + console.log(` prompt_cache_key: ${summary.promptCacheKey || "n/a"}`); + console.log(` inputLength: ${summary.inputLength ?? "n/a"}`); + if (summary.roles.length) { + console.log(` roles: ${summary.roles.join(", ")}`); + } + if (summary.reasoning?.effort || summary.reasoning?.summary) { + console.log( + ` reasoning: effort=${summary.reasoning.effort || "?"}, summary=${summary.reasoning.summary || "?"}`, + ); + } + if (summary.textVerbosity) { + console.log(` text verbosity: ${summary.textVerbosity}`); + } + if (Array.isArray(summary.include)) { + console.log(` include: ${summary.include.join(", ")}`); + } + if (summary.usage?.cached_tokens !== undefined) { + console.log(` cached_tokens: ${summary.usage.cached_tokens}`); + } + } + } +} + +async function main() { + const { dir, limit, id, stage } = parseFilters(); + try { + const requests = await collectLogs(dir, stage, id, limit); + if (requests.length === 0) { + console.log("No request logs found."); + return; + } + printSummary(requests); + } catch (error) { + console.error(`Failed to process logs: ${error.message}`); + process.exitCode = 1; + } +} + +main(); diff --git a/test/cache-e2e.test.ts b/test/cache-e2e.test.ts new file mode 100644 index 0000000..3c201ef --- /dev/null +++ b/test/cache-e2e.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { transformRequestForCodex } from "../lib/request/fetch-helpers.js"; +import { SessionManager } from "../lib/session/session-manager.js"; +import * as openCodeCodex from "../lib/prompts/opencode-codex.js"; +import type { InputItem, RequestBody, UserConfig } from "../lib/types.js"; +import * as logger from "../lib/logger.js"; + +const CODEX_INSTRUCTIONS = "codex instructions"; +const USER_CONFIG: UserConfig = { global: {}, models: {} }; +const API_URL = "https://api.openai.com/v1/responses"; + +function envMessage(date: string, files: string[]): InputItem { + return { + type: "message", + role: "developer", + content: [ + { + type: "input_text", + text: [ + "Here is some useful information about the environment you are running in:", + "", + ` Today's date: ${date}`, + "", + "", + ...files.map((f) => ` ${f}`), + "", + ].join("\n"), + }, + ], + }; +} + +async function runTransform(body: RequestBody, sessionManager: SessionManager) { + const init: RequestInit = { body: JSON.stringify(body) }; + const result = await transformRequestForCodex( + init, + API_URL, + CODEX_INSTRUCTIONS, + USER_CONFIG, + true, + sessionManager, + ); + if (!result) throw new Error("transformRequestForCodex returned undefined"); + return result; +} + +describe("cache e2e without hitting Codex", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps prompt_cache_key stable when env/files churn across turns", async () => { + // Avoid network in filterOpenCodeSystemPrompts + vi.spyOn(openCodeCodex, "getOpenCodeCodexPrompt").mockResolvedValue( + "You are a coding agent running in OpenCode", + ); + + const manager = new SessionManager({ enabled: true }); + + const body1: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-env-e2e" }, + input: [ + envMessage("Mon Jan 01 2024", ["README.md", "dist/index.js"]), + { type: "message", role: "user", content: "hello" }, + ], + }; + + const res1 = await runTransform(body1, manager); + const transformed1 = res1.body as RequestBody; + expect(transformed1.prompt_cache_key).toContain("conv-env-e2e"); + expect(transformed1.input).toHaveLength(1); + expect(transformed1.input?.[0].role).toBe("user"); + + const body2: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-env-e2e" }, + input: [ + envMessage("Tue Jan 02 2024", ["README.md", "dist/main.js", "coverage/index.html"]), + { type: "message", role: "user", content: "hello" }, + ], + }; + + const res2 = await runTransform(body2, manager); + const transformed2 = res2.body as RequestBody; + expect(transformed2.prompt_cache_key).toBe(transformed1.prompt_cache_key); + expect(transformed2.input).toHaveLength(1); + expect(transformed2.input?.[0].role).toBe("user"); + }); + + it("logs user_message_changed when only user content changes", async () => { + vi.spyOn(openCodeCodex, "getOpenCodeCodexPrompt").mockResolvedValue( + "You are a coding agent running in OpenCode", + ); + const warnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {}); + const manager = new SessionManager({ enabled: true }); + + const body1: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-user-e2e" }, + input: [{ type: "message", role: "user", content: "hello" }], + }; + await runTransform(body1, manager); + + const body2: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-user-e2e" }, + input: [{ type: "message", role: "user", content: "second" }], + }; + await runTransform(body2, manager); + + const warnCall = warnSpy.mock.calls.find( + ([message]) => typeof message === "string" && message.includes("prefix mismatch"), + ); + expect(warnCall?.[1]).toMatchObject({ + prefixCause: "user_message_changed", + previousRole: "user", + incomingRole: "user", + }); + }); +}); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 1a70029..9b9ab6e 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -587,6 +587,33 @@ describe("filterOpenCodeSystemPrompts", () => { expect(result).toHaveLength(2); }); + it("should drop env-only system messages", async () => { + const input: InputItem[] = [ + { + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: [ + "Here is some useful information about the environment you are running in:", + "", + " Working directory: /tmp", + "", + "", + " tmpfile.txt", + "", + ].join("\n"), + }, + ], + }, + { type: "message", role: "user", content: "hello" }, + ]; + const result = await filterOpenCodeSystemPrompts(input); + expect(result).toHaveLength(1); + expect(result![0].role).toBe("user"); + }); + it("should keep AGENTS.md content (not filter it)", async () => { const input: InputItem[] = [ { @@ -608,7 +635,7 @@ describe("filterOpenCodeSystemPrompts", () => { expect(result![1].role).toBe("user"); }); - it("should keep environment+AGENTS.md concatenated message", async () => { + it("should strip environment blocks but keep AGENTS.md content", async () => { const input: InputItem[] = [ { type: "message", @@ -618,16 +645,32 @@ describe("filterOpenCodeSystemPrompts", () => { { type: "message", role: "developer", - // environment + AGENTS.md joined (like OpenCode does) - content: - "Working directory: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions.", + // environment + files + AGENTS.md joined (like OpenCode does) + content: [ + { + type: "input_text", + text: [ + "Here is some useful information about the environment you are running in:", + "", + " Working directory: /path/to/project", + " Is directory a git repo: yes", + "", + "", + " README.md", + "", + "\n# AGENTS.md\n\nCustom instructions.", + ].join("\n"), + }, + ], }, { type: "message", role: "user", content: "hello" }, ]; const result = await filterOpenCodeSystemPrompts(input); - // Should filter first message (codex.txt) but keep second (env+AGENTS.md) + // Should filter codex.txt, strip env/files, and keep AGENTS.md text expect(result).toHaveLength(2); expect(result![0].content).toContain("AGENTS.md"); + expect(result![0].content as string).not.toContain(""); + expect(result![0].content as string).not.toContain(""); expect(result![1].role).toBe("user"); }); @@ -636,6 +679,100 @@ describe("filterOpenCodeSystemPrompts", () => { }); }); +describe("transformRequestBody caching stability", () => { + const CODEX_INSTRUCTIONS = "codex instructions"; + const userConfig = { global: {}, models: {} }; + + function envMessage(date: string, files: string[]): InputItem { + return { + type: "message", + role: "developer", + content: [ + { + type: "input_text", + text: [ + "Here is some useful information about the environment you are running in:", + "", + ` Today's date: ${date}`, + "", + "", + ...files.map((f) => ` ${f}`), + "", + ].join("\n"), + }, + ], + }; + } + + it("keeps prompt_cache_key stable when only env/files churn", async () => { + const body1: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-env-stable" }, + input: [ + envMessage("Mon Jan 01 2024", ["README.md", "dist/index.js"]), + { type: "message", role: "user", content: "hello" }, + ], + }; + + const result1 = await transformRequestBody(body1, CODEX_INSTRUCTIONS, userConfig, true, {}, undefined); + expect(result1.prompt_cache_key).toContain("conv-env-stable"); + expect(result1.input).toHaveLength(1); + expect(result1.input?.[0].role).toBe("user"); + + const body2: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-env-stable" }, + input: [ + envMessage("Tue Jan 02 2024", ["README.md", "dist/main.js", "coverage/index.html"]), + { type: "message", role: "user", content: "hello" }, + ], + }; + + const result2 = await transformRequestBody(body2, CODEX_INSTRUCTIONS, userConfig, true, {}, undefined); + expect(result2.prompt_cache_key).toBe(result1.prompt_cache_key); + expect(result2.input).toHaveLength(1); + expect(result2.input?.[0].role).toBe("user"); + }); + + it("can reattach env/files tail when flag enabled", async () => { + process.env.CODEX_APPEND_ENV_CONTEXT = "1"; + const body: RequestBody = { + model: "gpt-5", + metadata: { conversation_id: "conv-env-tail" }, + input: [ + { + type: "message", + role: "developer", + content: [ + { + type: "input_text", + text: [ + "Here is some useful information about the environment you are running in:", + "", + " Working directory: /tmp", + "", + "", + " tmpfile.txt", + "", + ].join("\n"), + }, + ], + }, + { type: "message", role: "user", content: "hello" }, + ], + }; + + const result = await transformRequestBody(body, CODEX_INSTRUCTIONS, userConfig, true, {}, undefined); + expect(result.input?.length).toBe(2); + expect(result.input?.[0].role).toBe("user"); + expect(result.input?.[1].role).toBe("developer"); + expect(result.input?.[1].content as string).toContain(""); + expect(result.input?.[1].content as string).toContain(""); + + delete process.env.CODEX_APPEND_ENV_CONTEXT; + }); +}); + describe("addCodexBridgeMessage", () => { it("should prepend bridge message when tools present", async () => { const input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "test" }] }]; diff --git a/test/session-manager.test.ts b/test/session-manager.test.ts index e020086..8281e29 100644 --- a/test/session-manager.test.ts +++ b/test/session-manager.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { SESSION_CONFIG } from "../lib/constants.js"; import { SessionManager } from "../lib/session/session-manager.js"; import * as logger from "../lib/logger.js"; -import type { InputItem, RequestBody, SessionContext } from "../lib/types.js"; +import type { RequestBody, SessionContext } from "../lib/types.js"; interface BodyOptions { forkId?: string; @@ -49,13 +49,9 @@ describe("SessionManager", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-123"); - let context = manager.getContext(body) as SessionContext; - expect(context.enabled).toBe(true); - expect(context.isNew).toBe(true); - expect(context.preserveIds).toBe(true); - expect(context.state.promptCacheKey).toBe("conv-123"); + const context = manager.getContext(body) as SessionContext; + manager.applyRequest(body, context); - context = manager.applyRequest(body, context) as SessionContext; expect(body.prompt_cache_key).toBe("conv-123"); expect(context.state.lastInput.length).toBe(1); }); @@ -78,67 +74,71 @@ describe("SessionManager", () => { }); it("regenerates cache key when prefix differs", () => { + const warnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {}); const manager = new SessionManager({ enabled: true }); const baseBody = createBody("conv-789", 2); - let context = manager.getContext(baseBody) as SessionContext; - context = manager.applyRequest(baseBody, context) as SessionContext; + const context = manager.getContext(baseBody) as SessionContext; + manager.applyRequest(baseBody, context); - const branchBody: RequestBody = { - model: "gpt-5", - metadata: { conversation_id: "conv-789" }, + const changedBody: RequestBody = { + ...baseBody, input: [ - { - type: "message", - role: "user", - id: "new_msg", - content: "fresh-start", - }, + { type: "message", role: "system", content: "updated system prompt" }, + { type: "message", role: "user", content: "hello" }, ], }; - let branchContext = manager.getContext(branchBody) as SessionContext; - branchContext = manager.applyRequest(branchBody, branchContext) as SessionContext; + const nextContext = manager.getContext(changedBody) as SessionContext; + manager.applyRequest(changedBody, nextContext); + + const warnCall = warnSpy.mock.calls.find( + ([message]) => typeof message === "string" && message.includes("prefix mismatch"), + ); + + expect(warnCall?.[1]).toMatchObject({ + prefixCause: "system_prompt_changed", + previousRole: "system", + incomingRole: "system", + }); - expect(branchBody.prompt_cache_key).toMatch(/^cache_/); - expect(branchContext.isNew).toBe(true); - expect(branchContext.state.promptCacheKey).not.toBe(context.state.promptCacheKey); + warnSpy.mockRestore(); }); - it("logs system prompt changes when regenerating cache key", () => { + it("does not warn on user-only content changes", () => { const warnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {}); const manager = new SessionManager({ enabled: true }); const baseBody: RequestBody = { model: "gpt-5", - metadata: { conversation_id: "conv-system-change" }, + metadata: { conversation_id: "conv-user-change" }, input: [ - { type: "message", role: "system", content: "initial system" }, - { type: "message", role: "user", content: "hello" }, + { type: "message", role: "system", content: "sys" }, + { type: "message", role: "user", content: "first" }, ], }; - let context = manager.getContext(baseBody) as SessionContext; - context = manager.applyRequest(baseBody, context) as SessionContext; + const context = manager.getContext(baseBody) as SessionContext; + manager.applyRequest(baseBody, context); - const changedBody: RequestBody = { + const nextBody: RequestBody = { ...baseBody, input: [ - { type: "message", role: "system", content: "updated system prompt" }, - { type: "message", role: "user", content: "hello" }, + { type: "message", role: "system", content: "sys" }, + { type: "message", role: "user", content: "second" }, ], }; - const nextContext = manager.getContext(changedBody) as SessionContext; - manager.applyRequest(changedBody, nextContext); + const nextContext = manager.getContext(nextBody) as SessionContext; + manager.applyRequest(nextBody, nextContext); const warnCall = warnSpy.mock.calls.find( ([message]) => typeof message === "string" && message.includes("prefix mismatch"), ); - expect(warnCall?.[1]).toMatchObject({ - prefixCause: "system_prompt_changed", - previousRole: "system", - incomingRole: "system", + prefixCause: "user_message_changed", + previousRole: "user", + incomingRole: "user", + sharedPrefixLength: 1, }); warnSpy.mockRestore(); @@ -164,8 +164,8 @@ describe("SessionManager", () => { ], }; - let context = manager.getContext(fullBody) as SessionContext; - context = manager.applyRequest(fullBody, context) as SessionContext; + const context = manager.getContext(fullBody) as SessionContext; + manager.applyRequest(fullBody, context); const prunedBody: RequestBody = { ...fullBody, @@ -192,8 +192,8 @@ describe("SessionManager", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-usage"); - let context = manager.getContext(body) as SessionContext; - context = manager.applyRequest(body, context) as SessionContext; + const context = manager.getContext(body) as SessionContext; + manager.applyRequest(body, context); manager.recordResponse(context, { usage: { cached_tokens: 42 } }); @@ -203,8 +203,8 @@ describe("SessionManager", () => { it("reports metrics snapshot with recent sessions", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-metrics"); - let context = manager.getContext(body) as SessionContext; - context = manager.applyRequest(body, context) as SessionContext; + const context = manager.getContext(body) as SessionContext; + manager.applyRequest(body, context); const metrics = manager.getMetrics(); expect(metrics.enabled).toBe(true); @@ -273,7 +273,7 @@ describe("SessionManager", () => { it("derives fork ids from parent conversation hints", () => { const manager = new SessionManager({ enabled: true }); const parentBody = createBody("conv-fork-parent", 1, { parentConversationId: "parent-conv" }); - let parentContext = manager.getContext(parentBody) as SessionContext; + const parentContext = manager.getContext(parentBody) as SessionContext; expect(parentContext.isNew).toBe(true); expect(parentContext.state.promptCacheKey).toBe("conv-fork-parent::fork::parent-conv"); manager.applyRequest(parentBody, parentContext); @@ -306,8 +306,9 @@ describe("SessionManager", () => { const totalSessions = SESSION_CONFIG.MAX_ENTRIES + 5; for (let index = 0; index < totalSessions; index += 1) { const body = createBody(`conv-cap-${index}`); - let context = manager.getContext(body) as SessionContext; - context = manager.applyRequest(body, context) as SessionContext; + const context = manager.getContext(body) as SessionContext; + manager.applyRequest(body, context); + context.state.lastUpdated -= index; // ensure ordering } From 138b52021515096fe0768008c4e47a3e46f9c90a Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 20:09:13 -0600 Subject: [PATCH 12/17] Fix prefix mismatch test expectation for user prior role --- test/session-manager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/session-manager.test.ts b/test/session-manager.test.ts index 8281e29..6f5d84f 100644 --- a/test/session-manager.test.ts +++ b/test/session-manager.test.ts @@ -98,7 +98,7 @@ describe("SessionManager", () => { expect(warnCall?.[1]).toMatchObject({ prefixCause: "system_prompt_changed", - previousRole: "system", + previousRole: "user", incomingRole: "system", }); From 39fca5729abd4c8d24de96d2f2973584d0366386 Mon Sep 17 00:00:00 2001 From: Error Date: Fri, 21 Nov 2025 20:27:03 -0600 Subject: [PATCH 13/17] Add configurable env tail append option --- lib/config.ts | 29 ++++++++++++++----------- lib/request/fetch-helpers.ts | 4 +++- lib/request/request-transformer.ts | 18 ++++++++++++---- lib/types.ts | 6 ++++++ spec/append-env-context-config.md | 29 +++++++++++++++++++++++++ test/cache-e2e.test.ts | 11 +++++++--- test/plugin-config.test.ts | 34 ++++++++++++++++++++++++++++++ test/request-transformer.test.ts | 30 ++++++++++++++++++++------ 8 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 spec/append-env-context-config.md diff --git a/lib/config.ts b/lib/config.ts index 677926c..e7ed695 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -9,14 +9,17 @@ const CONFIG_PATH = getOpenCodePath("openhax-codex-config.json"); * CODEX_MODE is enabled by default for better Codex CLI parity * Prompt caching is enabled by default to optimize token usage and reduce costs */ -const DEFAULT_CONFIG: PluginConfig = { - codexMode: true, - enablePromptCaching: true, - logging: { - showWarningToasts: false, - logWarningsToConsole: false, - }, -}; +function getDefaultConfig(): PluginConfig { + return { + codexMode: true, + enablePromptCaching: true, + appendEnvContext: process.env.CODEX_APPEND_ENV_CONTEXT === "1", + logging: { + showWarningToasts: false, + logWarningsToConsole: false, + }, + }; +} let cachedPluginConfig: PluginConfig | undefined; @@ -38,10 +41,11 @@ export function loadPluginConfig(options: { forceReload?: boolean } = {}): Plugi } try { + const defaults = getDefaultConfig(); const fileContent = safeReadFile(CONFIG_PATH); if (!fileContent) { logWarn("Plugin config file not found, using defaults", { path: CONFIG_PATH }); - cachedPluginConfig = { ...DEFAULT_CONFIG }; + cachedPluginConfig = { ...defaults }; return cachedPluginConfig; } @@ -50,20 +54,21 @@ export function loadPluginConfig(options: { forceReload?: boolean } = {}): Plugi // Merge with defaults (shallow merge + nested logging merge) cachedPluginConfig = { - ...DEFAULT_CONFIG, + ...defaults, ...userConfig, logging: { - ...DEFAULT_CONFIG.logging, + ...defaults.logging, ...userLogging, }, }; return cachedPluginConfig; } catch (error) { + const defaults = getDefaultConfig(); logWarn("Failed to load plugin config", { path: CONFIG_PATH, error: (error as Error).message, }); - cachedPluginConfig = { ...DEFAULT_CONFIG }; + cachedPluginConfig = { ...defaults }; return cachedPluginConfig; } } diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index f904197..41108f4 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -124,7 +124,7 @@ export async function transformRequestForCodex( userConfig: UserConfig, codexMode = true, sessionManager?: SessionManager, - _pluginConfig?: PluginConfig, + pluginConfig?: PluginConfig, ): Promise< | { body: RequestBody; @@ -160,7 +160,9 @@ export async function transformRequestForCodex( codexMode, { preserveIds: sessionContext?.preserveIds, + appendEnvContext: pluginConfig?.appendEnvContext ?? process.env.CODEX_APPEND_ENV_CONTEXT === "1", }, + sessionContext, ); const appliedContext = diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index f61f6b7..83fef9a 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -25,6 +25,8 @@ export { getModelConfig, getReasoningConfig, normalizeModel } from "./model-conf export interface TransformRequestOptions { /** Preserve IDs when prompt caching requires it. */ preserveIds?: boolean; + /** Reattach env/files context to prompt tail (defaults from config/env). */ + appendEnvContext?: boolean; } export interface TransformResult { @@ -37,6 +39,7 @@ async function transformInputForCodex( codexMode: boolean, preserveIds: boolean, hasNormalizedTools: boolean, + appendEnvContext: boolean, sessionContext?: SessionContext, ): Promise { if (!body.input || !Array.isArray(body.input)) { @@ -62,8 +65,7 @@ async function transformInputForCodex( } if (codexMode) { - const appendEnvTail = process.env.CODEX_APPEND_ENV_CONTEXT === "1"; - if (appendEnvTail) { + if (appendEnvContext) { const result = await filterOpenCodeSystemPromptsWithEnv(workingInput); workingInput = result?.input; if (result?.envSegments?.length) { @@ -126,8 +128,16 @@ export async function transformRequestBody( logCacheKeyDecision(cacheKeyResult, isNewSession); const hasNormalizedTools = normalizeToolsForCodexBody(body, false); - - await transformInputForCodex(body, codexMode, preserveIds, hasNormalizedTools, sessionContext); + const appendEnvContext = options.appendEnvContext ?? process.env.CODEX_APPEND_ENV_CONTEXT === "1"; + + await transformInputForCodex( + body, + codexMode, + preserveIds, + hasNormalizedTools, + appendEnvContext, + sessionContext, + ); const reasoningConfig = getReasoningConfig(originalModel, modelConfig); body.reasoning = { diff --git a/lib/types.ts b/lib/types.ts index 9567d9c..da74ab4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -17,6 +17,12 @@ export interface PluginConfig { */ enablePromptCaching?: boolean; + /** + * Reattach stripped environment/file context to the end of the prompt + * Default inherits from CODEX_APPEND_ENV_CONTEXT env var + */ + appendEnvContext?: boolean; + /** * Logging configuration that can override environment variables */ diff --git a/spec/append-env-context-config.md b/spec/append-env-context-config.md new file mode 100644 index 0000000..c0aebc9 --- /dev/null +++ b/spec/append-env-context-config.md @@ -0,0 +1,29 @@ +# Append Env Context Config + +## Scope + +- Add `appendEnvContext` to plugin config with default derived from `CODEX_APPEND_ENV_CONTEXT`. +- Route env/file tail reattachment through config/options instead of global env in transforms. +- Make tests explicit about env-tail behavior to avoid leaked env state. + +## Touched Files + +- lib/types.ts (PluginConfig additions) +- lib/config.ts (default config from env, merge logic) +- lib/request/request-transformer.ts (options-driven appendEnvContext handling) +- lib/request/fetch-helpers.ts (propagate config to transform) +- test/request-transformer.test.ts (explicit appendEnvContext expectations) +- test/cache-e2e.test.ts (pass plugin config with appendEnvContext=false) +- test/plugin-config.test.ts (cover env default + overrides, reset env) + +## Definition of Done + +- appendEnvContext configurable via config file with default from env. +- Transform respects config-driven appendEnvContext; no hard env dependency in tests. +- Cache/request-transformer tests pass with explicit config; plugin config tests cover new field. +- pnpm test test/cache-e2e.test.ts test/request-transformer.test.ts test/plugin-config.test.ts succeeds. + +## Notes + +- No existing issues/PRs linked for this change. +- Defaults recalc on load; `forceReload` picks up env changes. diff --git a/test/cache-e2e.test.ts b/test/cache-e2e.test.ts index 3c201ef..864efe8 100644 --- a/test/cache-e2e.test.ts +++ b/test/cache-e2e.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { transformRequestForCodex } from "../lib/request/fetch-helpers.js"; import { SessionManager } from "../lib/session/session-manager.js"; import * as openCodeCodex from "../lib/prompts/opencode-codex.js"; -import type { InputItem, RequestBody, UserConfig } from "../lib/types.js"; +import type { InputItem, PluginConfig, RequestBody, UserConfig } from "../lib/types.js"; import * as logger from "../lib/logger.js"; const CODEX_INSTRUCTIONS = "codex instructions"; @@ -30,7 +30,11 @@ function envMessage(date: string, files: string[]): InputItem { }; } -async function runTransform(body: RequestBody, sessionManager: SessionManager) { +async function runTransform( + body: RequestBody, + sessionManager: SessionManager, + pluginConfig: PluginConfig = { appendEnvContext: false }, +) { const init: RequestInit = { body: JSON.stringify(body) }; const result = await transformRequestForCodex( init, @@ -39,6 +43,7 @@ async function runTransform(body: RequestBody, sessionManager: SessionManager) { USER_CONFIG, true, sessionManager, + pluginConfig, ); if (!result) throw new Error("transformRequestForCodex returned undefined"); return result; diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 513b98e..39cff8a 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -29,9 +29,13 @@ beforeEach(async () => { describe("Plugin Configuration", () => { let originalEnv: string | undefined; + let originalAppendEnv: string | undefined; beforeEach(() => { originalEnv = process.env.CODEX_MODE; + originalAppendEnv = process.env.CODEX_APPEND_ENV_CONTEXT; + delete process.env.CODEX_MODE; + delete process.env.CODEX_APPEND_ENV_CONTEXT; vi.clearAllMocks(); }); @@ -41,6 +45,12 @@ describe("Plugin Configuration", () => { } else { process.env.CODEX_MODE = originalEnv; } + + if (originalAppendEnv === undefined) { + delete process.env.CODEX_APPEND_ENV_CONTEXT; + } else { + process.env.CODEX_APPEND_ENV_CONTEXT = originalAppendEnv; + } }); describe("loadPluginConfig", () => { @@ -52,6 +62,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, + appendEnvContext: false, logging: { showWarningToasts: false, logWarningsToConsole: false }, }); @@ -69,6 +80,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: false, enablePromptCaching: true, + appendEnvContext: false, logging: { showWarningToasts: false, logWarningsToConsole: false }, }); }); @@ -82,10 +94,30 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, + appendEnvContext: false, logging: { showWarningToasts: false, logWarningsToConsole: false }, }); }); + it("should default appendEnvContext from env when config missing", () => { + process.env.CODEX_APPEND_ENV_CONTEXT = "1"; + mockExistsSync.mockReturnValue(false); + + const config = loadPluginConfig({ forceReload: true }); + + expect(config.appendEnvContext).toBe(true); + }); + + it("should let config override appendEnvContext even when env is set", () => { + process.env.CODEX_APPEND_ENV_CONTEXT = "1"; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ appendEnvContext: false })); + + const config = loadPluginConfig({ forceReload: true }); + + expect(config.appendEnvContext).toBe(false); + }); + it("should merge nested logging config with defaults", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( @@ -112,6 +144,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, + appendEnvContext: false, logging: { showWarningToasts: false, logWarningsToConsole: false }, }); @@ -131,6 +164,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, + appendEnvContext: false, logging: { showWarningToasts: false, logWarningsToConsole: false }, }); expect(logWarnSpy).toHaveBeenCalled(); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 9b9ab6e..9198335 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -714,7 +714,14 @@ describe("transformRequestBody caching stability", () => { ], }; - const result1 = await transformRequestBody(body1, CODEX_INSTRUCTIONS, userConfig, true, {}, undefined); + const result1 = await transformRequestBody( + body1, + CODEX_INSTRUCTIONS, + userConfig, + true, + { appendEnvContext: false }, + undefined, + ); expect(result1.prompt_cache_key).toContain("conv-env-stable"); expect(result1.input).toHaveLength(1); expect(result1.input?.[0].role).toBe("user"); @@ -728,14 +735,20 @@ describe("transformRequestBody caching stability", () => { ], }; - const result2 = await transformRequestBody(body2, CODEX_INSTRUCTIONS, userConfig, true, {}, undefined); + const result2 = await transformRequestBody( + body2, + CODEX_INSTRUCTIONS, + userConfig, + true, + { appendEnvContext: false }, + undefined, + ); expect(result2.prompt_cache_key).toBe(result1.prompt_cache_key); expect(result2.input).toHaveLength(1); expect(result2.input?.[0].role).toBe("user"); }); it("can reattach env/files tail when flag enabled", async () => { - process.env.CODEX_APPEND_ENV_CONTEXT = "1"; const body: RequestBody = { model: "gpt-5", metadata: { conversation_id: "conv-env-tail" }, @@ -762,14 +775,19 @@ describe("transformRequestBody caching stability", () => { ], }; - const result = await transformRequestBody(body, CODEX_INSTRUCTIONS, userConfig, true, {}, undefined); + const result = await transformRequestBody( + body, + CODEX_INSTRUCTIONS, + userConfig, + true, + { appendEnvContext: true }, + undefined, + ); expect(result.input?.length).toBe(2); expect(result.input?.[0].role).toBe("user"); expect(result.input?.[1].role).toBe("developer"); expect(result.input?.[1].content as string).toContain(""); expect(result.input?.[1].content as string).toContain(""); - - delete process.env.CODEX_APPEND_ENV_CONTEXT; }); }); From 588d2260402b03c9c1f865e5d8470cfec32bdd5d Mon Sep 17 00:00:00 2001 From: Err Date: Fri, 21 Nov 2025 20:34:25 -0600 Subject: [PATCH 14/17] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- test/cache-e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cache-e2e.test.ts b/test/cache-e2e.test.ts index 864efe8..4fea7fd 100644 --- a/test/cache-e2e.test.ts +++ b/test/cache-e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { transformRequestForCodex } from "../lib/request/fetch-helpers.js"; import { SessionManager } from "../lib/session/session-manager.js"; import * as openCodeCodex from "../lib/prompts/opencode-codex.js"; From 2478ca2825340d6a3659c5722f3bc9edd69165ee Mon Sep 17 00:00:00 2001 From: Error Date: Sat, 22 Nov 2025 10:55:22 -0600 Subject: [PATCH 15/17] Refactor session manager item equality --- lib/session/session-manager.ts | 12 ++++++++++-- spec/session-manager-items-equality.md | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 spec/session-manager-items-equality.md diff --git a/lib/session/session-manager.ts b/lib/session/session-manager.ts index c616d24..c1f363b 100644 --- a/lib/session/session-manager.ts +++ b/lib/session/session-manager.ts @@ -19,6 +19,14 @@ function computeHash(items: InputItem[]): string { return createHash("sha1").update(JSON.stringify(items)).digest("hex"); } +function itemsEqual(a: InputItem | undefined, b: InputItem | undefined): boolean { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + function longestSharedPrefixLength(previous: InputItem[], current: InputItem[]): number { if (previous.length === 0 || current.length === 0) { return 0; @@ -28,7 +36,7 @@ function longestSharedPrefixLength(previous: InputItem[], current: InputItem[]): let length = 0; for (let i = 0; i < limit; i += 1) { - if (JSON.stringify(previous[i]) !== JSON.stringify(current[i])) { + if (!itemsEqual(previous[i], current[i])) { break; } length += 1; @@ -95,7 +103,7 @@ function findSuffixReuseStart(previous: InputItem[], current: InputItem[]): numb const start = previous.length - current.length; for (let index = 0; index < current.length; index += 1) { const prevItem = previous[start + index]; - if (JSON.stringify(prevItem) !== JSON.stringify(current[index])) { + if (!itemsEqual(prevItem, current[index])) { return null; } } diff --git a/spec/session-manager-items-equality.md b/spec/session-manager-items-equality.md new file mode 100644 index 0000000..39b5639 --- /dev/null +++ b/spec/session-manager-items-equality.md @@ -0,0 +1,26 @@ +# Session Manager item equality helper + +## Scope and references + +- lib/session/session-manager.ts: longestSharedPrefixLength (around lines 22-37) uses JSON.stringify comparison +- lib/session/session-manager.ts: findSuffixReuseStart (around lines 92-103) uses JSON.stringify comparison + +## Existing issues + +- None observed related to this refactor (no issue referenced in request) + +## Existing PRs + +- None observed; task requested directly by review comment + +## Definition of done + +- Shared helper (e.g., itemsEqual) added to centralize equality check with safe JSON stringify in try/catch +- longestSharedPrefixLength and findSuffixReuseStart use the helper instead of inline JSON.stringify comparisons +- Build/test commands remain passing or noted if not run + +## Requirements + +- Avoid duplicated JSON.stringify comparisons; centralize error handling for failed stringify +- Apply helper in both functions mentioned in review +- Create new branch and open PR targeting dev when changes are complete From 9a8b3e07d3dda61b7fc73706b5aa4b26129944c3 Mon Sep 17 00:00:00 2001 From: Error Date: Sat, 22 Nov 2025 11:25:45 -0600 Subject: [PATCH 16/17] Handle computeHash stringify failures --- lib/session/session-manager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/session/session-manager.ts b/lib/session/session-manager.ts index c1f363b..4dc0ad4 100644 --- a/lib/session/session-manager.ts +++ b/lib/session/session-manager.ts @@ -16,7 +16,11 @@ export interface SessionManagerOptions { // Clone utilities now imported from ../utils/clone.ts function computeHash(items: InputItem[]): string { - return createHash("sha1").update(JSON.stringify(items)).digest("hex"); + try { + return createHash("sha1").update(JSON.stringify(items)).digest("hex"); + } catch { + return createHash("sha1").update(`fallback_${items.length}`).digest("hex"); + } } function itemsEqual(a: InputItem | undefined, b: InputItem | undefined): boolean { From 89a27e5b2e41d2628ed56c40ae6209fc87b7b4c5 Mon Sep 17 00:00:00 2001 From: Err Date: Sat, 22 Nov 2025 12:10:26 -0600 Subject: [PATCH 17/17] chore: release v0.4.4 (PR #78) (#79) Co-authored-by: github-actions[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e2b714..4323bbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openhax/codex", - "version": "0.4.3", + "version": "0.4.4", "description": "OpenHax Codex OAuth plugin for Opencode — bring your ChatGPT Plus/Pro subscription instead of API credits", "main": "./dist/index.js", "types": "./dist/index.d.ts",