fix(codex-rules): dedupe compacted context

This commit is contained in:
YeonGyu-Kim
2026-05-28 14:44:14 +09:00
parent 16c0844421
commit f0af25aab1
3 changed files with 388 additions and 54 deletions
@@ -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<string> {
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<LoadedRule>,
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, {
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -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<typeof runUserPromptSubmitHook>[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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}