diff --git a/README.md b/README.md index 59947de..77b3c78 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,6 @@ 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: @@ -565,8 +562,6 @@ If you want to customize settings yourself, you can configure options at provide > > † **Extra High reasoning**: `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is only available on `gpt-5.1-codex-max`. -See [Plugin-Level Settings](#plugin-level-settings) above for global toggles. Below are provider/model examples. - #### Global Configuration Example Apply settings to all models: 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 3616dd2..075fcb9 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, logging: { showWarningToasts: false, }, 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 c939039..8d94ebf 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -17,22 +17,6 @@ export interface PluginConfig { */ 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; - /** * Logging configuration that can override environment variables */ 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 6a30af5..f213f47 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, logging: { showWarningToasts: false }, }); @@ -71,8 +69,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: false, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, logging: { showWarningToasts: false }, }); }); @@ -86,8 +82,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, logging: { showWarningToasts: false }, }); }); @@ -117,10 +111,8 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, logging: { showWarningToasts: false }, - }); + }); expect(logWarnSpy).toHaveBeenCalled(); logWarnSpy.mockRestore(); }); @@ -137,8 +129,6 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, enablePromptCaching: true, - enableCodexCompaction: true, - autoCompactMinMessages: 8, logging: { showWarningToasts: false }, }); expect(logWarnSpy).toHaveBeenCalled();