From f0af25aab1ab05349d758240025beac8217a5108 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 28 May 2026 14:44:14 +0900 Subject: [PATCH] fix(codex-rules): dedupe compacted context --- .../plugin/components/rules/src/codex-hook.ts | 66 +---- .../components/rules/src/transcript-search.ts | 108 +++++++ .../codex-hook-post-compact-dedup.test.ts | 268 ++++++++++++++++++ 3 files changed, 388 insertions(+), 54 deletions(-) create mode 100644 packages/omo-codex/plugin/components/rules/src/transcript-search.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts diff --git a/packages/omo-codex/plugin/components/rules/src/codex-hook.ts b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts index 8a29d2455..07646add9 100644 --- a/packages/omo-codex/plugin/components/rules/src/codex-hook.ts +++ b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts @@ -20,6 +20,8 @@ import { sortCandidates } from "./rules/ordering.js"; import { findProjectRoot } from "./rules/project-root.js"; import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js"; import { extractCodexToolPaths } from "./tool-paths.js"; +import type { TranscriptSearchOptions } from "./transcript-search.js"; +import { readTranscriptSearchText } from "./transcript-search.js"; type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; @@ -90,7 +92,7 @@ export async function runSessionStartHook( clearSessionState(cachePath); } const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static"); - const transcriptPath = input.source === "clear" || postCompactPending ? null : input.transcript_path; + const transcriptPath = input.source === "clear" ? null : input.transcript_path; return runStaticInjection( input.cwd, transcriptPath, @@ -98,6 +100,7 @@ export async function runSessionStartHook( cachePath, options, postCompactPending ? "static" : undefined, + { latestCompactedReplacementOnly: postCompactPending }, ); } @@ -115,14 +118,14 @@ export async function runUserPromptSubmitHook( ): Promise { const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); const postCompactPending = isPostCompactPending(cachePath, "static"); - const transcriptPath = postCompactPending ? null : input.transcript_path; return runStaticInjection( input.cwd, - transcriptPath, + input.transcript_path, "UserPromptSubmit", cachePath, options, postCompactPending ? "static" : undefined, + { latestCompactedReplacementOnly: postCompactPending }, ); } @@ -152,7 +155,6 @@ export async function runPostToolUseHook( const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); const postCompactPending = isPostCompactPending(cachePath, "dynamic"); - const transcriptPath = postCompactPending ? null : input.transcript_path; const engine = createRulesEngine(options); hydrateEngineState(engine, cachePath); debugTimer.lap("hydrate", { @@ -180,10 +182,11 @@ export async function runPostToolUseHook( debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length }); const rules = filterRulesAlreadyInTranscript( loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)), - transcriptPath, + input.transcript_path, (rule) => { engine.markDynamicInjected(rule); }, + { latestCompactedReplacementOnly: postCompactPending }, ); debugTimer.lap("filter", { rules: rules.length }); for (const target of pendingTargetFingerprints) { @@ -216,6 +219,7 @@ function runStaticInjection( cachePath: string, options: CodexRulesHookOptions, completedPostCompactChannel?: "static", + transcriptSearchOptions: TranscriptSearchOptions = {}, ): string { const config = configFromEnvironment(options.env); if (config.disabled || config.mode === "off" || config.mode === "dynamic") { @@ -233,6 +237,7 @@ function runStaticInjection( (rule) => { engine.markStaticInjected(rule); }, + transcriptSearchOptions, ); if (rules.length === 0) { persistEngineState(engine, cachePath, completedPostCompactChannel); @@ -251,12 +256,13 @@ function filterRulesAlreadyInTranscript( rules: ReadonlyArray, transcriptPath: string | null, markInjected: (rule: LoadedRule) => void, + options: TranscriptSearchOptions = {}, ): LoadedRule[] { if (rules.length === 0 || transcriptPath === null) { return [...rules]; } - const transcriptText = readTranscriptSearchText(transcriptPath); + const transcriptText = readTranscriptSearchText(transcriptPath, options); if (transcriptText === null) { return [...rules]; } @@ -287,54 +293,6 @@ function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): bo return markers.some((marker) => transcriptText.includes(marker)); } -function readTranscriptSearchText(transcriptPath: string): string | null { - try { - const rawTranscript = readFileSync(transcriptPath, "utf8"); - return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n"); - } catch { - return null; - } -} - -function collectJsonLineStrings(rawTranscript: string): string[] { - const values: string[] = []; - for (const line of rawTranscript.split(/\r?\n/)) { - if (line.trim().length === 0) { - continue; - } - - try { - const parsed: unknown = JSON.parse(line); - collectStrings(parsed, values); - } catch { - // Non-JSON transcript lines are still covered by the raw transcript text. - } - } - return values; -} - -function collectStrings(value: unknown, output: string[]): void { - if (typeof value === "string") { - output.push(value); - return; - } - - if (Array.isArray(value)) { - for (const item of value) { - collectStrings(item, output); - } - return; - } - - if (typeof value !== "object" || value === null) { - return; - } - - for (const item of Object.values(value)) { - collectStrings(item, output); - } -} - function createRulesEngine(options: CodexRulesHookOptions) { const config = configFromEnvironment(options.env); return createEngine(config, { diff --git a/packages/omo-codex/plugin/components/rules/src/transcript-search.ts b/packages/omo-codex/plugin/components/rules/src/transcript-search.ts new file mode 100644 index 000000000..94dd73ff3 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/transcript-search.ts @@ -0,0 +1,108 @@ +import { readFileSync } from "node:fs"; + +export interface TranscriptSearchOptions { + readonly latestCompactedReplacementOnly?: boolean; +} + +export function readTranscriptSearchText(transcriptPath: string, options: TranscriptSearchOptions = {}): string | null { + try { + const rawTranscript = readFileSync(transcriptPath, "utf8"); + if (options.latestCompactedReplacementOnly === true) { + return latestCompactedReplacementSearchText(rawTranscript); + } + return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n"); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return null; + } +} + +function latestCompactedReplacementSearchText(rawTranscript: string): string | null { + const lines = rawTranscript.split(/\r?\n/); + let latestCompactedLineIndex = -1; + let replacementHistory: unknown[] | null = null; + for (const [index, line] of lines.entries()) { + const parsed = parseJsonLine(line); + if (!isRecord(parsed) || parsed["type"] !== "compacted") { + continue; + } + + const payload = parsed["payload"]; + if (!isRecord(payload)) { + continue; + } + + const candidateReplacementHistory = payload["replacement_history"]; + if (!Array.isArray(candidateReplacementHistory)) { + continue; + } + + latestCompactedLineIndex = index; + replacementHistory = candidateReplacementHistory; + } + + if (replacementHistory === null) { + return null; + } + + const values: string[] = []; + collectStrings(replacementHistory, values); + const laterTranscript = lines.slice(latestCompactedLineIndex + 1).join("\n"); + values.push(laterTranscript, ...collectJsonLineStrings(laterTranscript)); + return values.join("\n"); +} + +function collectJsonLineStrings(rawTranscript: string): string[] { + const values: string[] = []; + for (const line of rawTranscript.split(/\r?\n/)) { + const parsed = parseJsonLine(line); + if (parsed !== null) { + collectStrings(parsed, values); + } + } + return values; +} + +function parseJsonLine(line: string): unknown | null { + if (line.trim().length === 0) { + return null; + } + + try { + const parsed: unknown = JSON.parse(line); + return parsed; + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return null; + } +} + +function collectStrings(value: unknown, output: string[]): void { + if (typeof value === "string") { + output.push(value); + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectStrings(item, output); + } + return; + } + + if (!isRecord(value)) { + return; + } + + for (const item of Object.values(value)) { + collectStrings(item, output); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts new file mode 100644 index 000000000..725826f80 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-post-compact-dedup.test.ts @@ -0,0 +1,268 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + type CodexPostCompactInput, + type CodexPostToolUseInput, + type CodexSessionStartInput, + runPostCompactHook, + runPostToolUseHook, + runSessionStartHook, + runUserPromptSubmitHook, +} from "../src/codex-hook.js"; + +const tempDirectories: string[] = []; +const PROJECT_ONLY_ENV = { + CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules", +}; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("codex rules PostCompact deduplication", () => { + it("#given compacted replacement already retained static context #when UserPromptSubmit runs after PostCompact #then it emits no duplicate static context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const firstOutput = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const transcriptPath = writeTranscriptWithCompactedReplacement(root, readAdditionalContext(firstOutput)); + await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + + // when + const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + + // then + expect(output).toBe(""); + }); + + it("#given compacted replacement already retained dynamic context #when PostToolUse runs after PostCompact #then it emits no duplicate dynamic context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + const input = postToolUseInput(root, filePath); + const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + const transcriptPath = writeTranscriptWithCompactedReplacement(root, readAdditionalContext(firstOutput)); + await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + + // when + const output = await runPostToolUseHook( + { ...input, transcript_path: transcriptPath }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(output).toBe(""); + }); + + it("#given malformed transcript with repeated compactions retaining context #when UserPromptSubmit runs after PostCompact #then it emits no duplicate static context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const firstOutput = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const transcriptPath = writeTranscriptWithRepeatedCompactions(root, readAdditionalContext(firstOutput)); + await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + + // when + const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + + // then + expect(output).toBe(""); + }); + + it("#given compacted replacement dropped static context #when UserPromptSubmit runs after PostCompact #then it re-injects static context", async () => { + // given + const { root, pluginData } = makeTempProject(); + await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const transcriptPath = writeTranscriptWithCompactedReplacement(root, "summary without project instructions"); + await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + + // when + const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + + // then + expect(readAdditionalContext(output)).toContain("Always wear safety goggles"); + }); +}); + +function makeTempProject(): { root: string; pluginData: string } { + const root = mkdtempSync(path.join(tmpdir(), "codex-rules-compact-dedup-project-")); + const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-compact-dedup-data-")); + tempDirectories.push(root, pluginData); + writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" })); + writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring."); + mkdirSync(path.join(root, ".omo", "rules"), { recursive: true }); + writeFileSync( + path.join(root, ".omo", "rules", "typescript.md"), + [ + "---", + "description: TypeScript", + 'globs: ["**/*.ts", "**/*.tsx"]', + "---", + "", + "Prefer strict TypeScript for all source files.", + ].join("\n"), + ); + mkdirSync(path.join(root, "src"), { recursive: true }); + writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n"); + return { root, pluginData }; +} + +function sessionStartInput(root: string): CodexSessionStartInput { + return { + session_id: "session-compact-dedup", + transcript_path: null, + cwd: root, + hook_event_name: "SessionStart", + model: "gpt-5.5", + permission_mode: "default", + source: "startup", + }; +} + +function postCompactInput(root: string): CodexPostCompactInput { + return { + session_id: "session-compact-dedup", + turn_id: "turn-compact", + transcript_path: null, + cwd: root, + hook_event_name: "PostCompact", + model: "gpt-5.5", + trigger: "manual", + }; +} + +function userPromptSubmitInput(root: string, transcriptPath: string): Parameters[0] { + return { + session_id: "session-compact-dedup", + turn_id: "turn-after-compact", + transcript_path: transcriptPath, + cwd: root, + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "read src/app.ts", + }; +} + +function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput { + return { + session_id: "session-compact-dedup", + turn_id: "turn-after-compact", + transcript_path: null, + cwd: root, + hook_event_name: "PostToolUse", + model: "gpt-5.5", + permission_mode: "default", + tool_name: "mcp__filesystem__read_file", + tool_input: { path: filePath }, + tool_response: { text: "file contents" }, + tool_use_id: "call-1", + }; +} + +function writeTranscriptWithCompactedReplacement(root: string, ...replacementTexts: string[]): string { + const transcriptPath = path.join(root, "transcript-compacted.jsonl"); + const replacementHistory = replacementTexts.map((text) => ({ + type: "message", + role: "user", + content: [{ type: "input_text", text }], + })); + writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: "compacted", + payload: { + message: "summary", + replacement_history: replacementHistory, + }, + })}\n`, + ); + return transcriptPath; +} + +function writeTranscriptWithRepeatedCompactions(root: string, retainedText: string): string { + const transcriptPath = path.join(root, "transcript-repeated-compacted.jsonl"); + writeFileSync( + transcriptPath, + [ + "{not json", + JSON.stringify({ + type: "compacted", + payload: { + message: "older summary", + replacement_history: [{ type: "message", role: "user", content: "old summary without rules" }], + }, + }), + JSON.stringify({ + type: "message", + payload: { content: "x".repeat(10_000) }, + }), + JSON.stringify({ + type: "compacted", + payload: { + message: "latest summary", + replacement_history: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: retainedText }], + }, + ], + }, + }), + JSON.stringify({ + type: "message", + payload: { content: "later prompt after compact" }, + }), + "", + ].join("\n"), + ); + return transcriptPath; +} + +function readAdditionalContext(output: string): string { + expect(output.trim().length).toBeGreaterThan(0); + const parsed: unknown = JSON.parse(output); + if (!isRecord(parsed)) return ""; + const hookSpecificOutput = parsed["hookSpecificOutput"]; + if (!isRecord(hookSpecificOutput)) return ""; + const additionalContext = hookSpecificOutput["additionalContext"]; + return typeof additionalContext === "string" ? additionalContext : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}