feat(omo-codex): add post-compact rule budget to codex hook

Trim rule and result char budgets after compaction via new
postCompactMaxRuleChars/postCompactMaxResultChars config, applied
through withPostCompactBudget. Extract codex-hook helpers into
dynamic-target-fingerprints, hook-output, path-utils,
rules-engine-factory, and transcript-rule-filter modules.
This commit is contained in:
YeonGyu-Kim
2026-05-29 11:17:24 +09:00
parent 58871c4301
commit bb8ef30bbe
12 changed files with 381 additions and 201 deletions
@@ -1,8 +1,8 @@
import { readFileSync, statSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { configFromEnvironment } from "./config.js";
import { createHookDebugTimer } from "./debug-log.js";
import { fingerprintDynamicTargets } from "./dynamic-target-fingerprints.js";
import { formatAdditionalContextOutput } from "./hook-output.js";
import { displayPath, uniqueStrings } from "./path-utils.js";
import {
clearSessionState,
hasPostCompactPending,
@@ -12,18 +12,11 @@ import {
persistEngineState,
sessionCachePath,
} from "./persistent-cache.js";
import { SOURCE_PRIORITY } from "./rules/constants.js";
import { createEngine } from "./rules/engine.js";
import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
import { hashContent } from "./rules/matcher.js";
import { sortCandidates } from "./rules/ordering.js";
import { findProjectRoot } from "./rules/project-root.js";
import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js";
import { withPostCompactBudget } from "./post-compact-budget.js";
import { createRulesEngine } from "./rules-engine-factory.js";
import { extractCodexToolPaths } from "./tool-paths.js";
import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js";
import type { TranscriptSearchOptions } from "./transcript-search.js";
import { readTranscriptSearchText } from "./transcript-search.js";
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
export type CodexSessionStartInput = {
session_id: string;
@@ -75,12 +68,6 @@ export interface CodexRulesHookOptions {
pluginDataRoot?: string;
}
interface DynamicTargetFingerprint {
targetPath: string;
cacheKey: string;
fingerprint: string;
}
export async function runSessionStartHook(
input: CodexSessionStartInput,
options: CodexRulesHookOptions = {},
@@ -155,7 +142,7 @@ export async function runPostToolUseHook(
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
const engine = createRulesEngine(options);
const engine = createRulesEngine(options, postCompactPending ? withPostCompactBudget(config) : config);
hydrateEngineState(engine, cachePath);
debugTimer.lap("hydrate", {
dynamicDedupScopes: engine.state.dynamicDedup.size,
@@ -226,7 +213,8 @@ function runStaticInjection(
return "";
}
const engine = createRulesEngine(options);
const effectiveConfig = completedPostCompactChannel === undefined ? config : withPostCompactBudget(config);
const engine = createRulesEngine(options, effectiveConfig);
hydrateEngineState(engine, cachePath);
engine.state.cwd = cwd;
@@ -251,183 +239,3 @@ function runStaticInjection(
persistEngineState(engine, cachePath, completedPostCompactChannel);
return formatAdditionalContextOutput(eventName, block);
}
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, options);
if (transcriptText === null) {
return [...rules];
}
const pendingRules: LoadedRule[] = [];
for (const rule of rules) {
if (isRuleAlreadyInTranscript(rule, transcriptText)) {
markInjected(rule);
continue;
}
pendingRules.push(rule);
}
return pendingRules;
}
function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean {
const bodyNeedle = rule.body.trim().slice(0, 2_000);
if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) {
return false;
}
const markers = [
`Instructions from: ${rule.path}`,
`Instructions from: ${rule.realPath}`,
rule.relativePath.length === 0 ? null : rule.relativePath,
].filter((marker): marker is string => marker !== null);
return markers.some((marker) => transcriptText.includes(marker));
}
function createRulesEngine(options: CodexRulesHookOptions) {
const config = configFromEnvironment(options.env);
return createEngine(config, {
findCandidates: findRuleCandidates,
findProjectRoot,
readFile: (path) => {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
},
});
}
function fingerprintDynamicTargets(
cwd: string,
targetPaths: ReadonlyArray<string>,
config: PiRulesConfig,
): DynamicTargetFingerprint[] {
const disabledSources = disabledSourcesFor(config);
const discoveryCache = createRuleDiscoveryCache();
const cwdProjectRoot = findProjectRoot(cwd);
const fingerprints: DynamicTargetFingerprint[] = [];
for (const targetPath of uniqueStrings(targetPaths)) {
const projectRoot =
cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot)
? cwdProjectRoot
: findProjectRoot(targetPath);
const findOptions: {
projectRoot: string | null;
targetFile: string;
disabledSources?: ReadonlySet<string>;
cache: ReturnType<typeof createRuleDiscoveryCache>;
} = {
projectRoot,
targetFile: targetPath,
cache: discoveryCache,
};
if (disabledSources !== undefined) {
findOptions.disabledSources = disabledSources;
}
const candidates = findRuleCandidates(findOptions);
const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001");
const cacheKey = dynamicTargetCacheKey(targetPath);
fingerprints.push({
targetPath,
cacheKey,
fingerprint: hashContent(
[
"v1",
config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","),
projectRoot ?? "",
cacheKey,
candidateFingerprint,
].join("\u0000"),
),
});
}
return fingerprints;
}
function fingerprintCandidate(candidate: RuleCandidate): string {
return [
candidate.realPath,
candidate.relativePath,
candidate.source,
candidate.isGlobal ? "global" : "project",
candidate.isSingleFile ? "single" : "multi",
String(candidate.distance),
fileFingerprint(candidate.path),
].join("\u0000");
}
function fileFingerprint(filePath: string): string {
try {
const stats = statSync(filePath, { bigint: true });
return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`;
} catch {
return "missing";
}
}
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
if (config.enabledSources === "auto") {
return undefined;
}
const enabledSources = new Set(config.enabledSources);
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
}
function dynamicTargetCacheKey(targetPath: string): string {
return toPosixPath(resolve(targetPath));
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const childRelativePath = relative(parentPath, resolve(childPath));
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
}
function uniqueStrings(values: ReadonlyArray<string>): string[] {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
for (const value of values) {
if (seenValues.has(value)) {
continue;
}
seenValues.add(value);
uniqueValues.push(value);
}
return uniqueValues;
}
function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string {
if (additionalContext.trim().length === 0) return "";
return `${JSON.stringify({
hookSpecificOutput: {
hookEventName: eventName,
additionalContext,
},
})}\n`;
}
function displayPath(cwd: string, filePath: string): string {
const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;
// Normalize to POSIX separators so injected rule context renders the same
// path string on Linux/macOS and Windows (Codex feeds this verbatim into
// the model prompt, and the existing engine already emits POSIX paths).
return toPosixPath(rel);
}
function toPosixPath(path: string): string {
return path.replaceAll("\\", "/");
}
@@ -13,6 +13,14 @@ export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiR
config.maxResultChars =
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ??
config.maxResultChars;
config.postCompactMaxRuleChars =
parsePositiveInteger(
firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS"),
) ?? config.postCompactMaxRuleChars;
config.postCompactMaxResultChars =
parsePositiveInteger(
firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS"),
) ?? config.postCompactMaxResultChars;
config.enabledSources = parseEnabledSources(
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
disableBundledRules,
@@ -0,0 +1,98 @@
import { statSync } from "node:fs";
import { resolve } from "node:path";
import { isSameOrChildPath, toPosixPath, uniqueStrings } from "./path-utils.js";
import { SOURCE_PRIORITY } from "./rules/constants.js";
import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
import { hashContent } from "./rules/matcher.js";
import { sortCandidates } from "./rules/ordering.js";
import { findProjectRoot } from "./rules/project-root.js";
import type { PiRulesConfig, RuleCandidate } from "./rules/types.js";
export interface DynamicTargetFingerprint {
targetPath: string;
cacheKey: string;
fingerprint: string;
}
export function fingerprintDynamicTargets(
cwd: string,
targetPaths: ReadonlyArray<string>,
config: PiRulesConfig,
): DynamicTargetFingerprint[] {
const disabledSources = disabledSourcesFor(config);
const discoveryCache = createRuleDiscoveryCache();
const cwdProjectRoot = findProjectRoot(cwd);
const fingerprints: DynamicTargetFingerprint[] = [];
for (const targetPath of uniqueStrings(targetPaths)) {
const projectRoot =
cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot)
? cwdProjectRoot
: findProjectRoot(targetPath);
const findOptions: {
projectRoot: string | null;
targetFile: string;
disabledSources?: ReadonlySet<string>;
cache: ReturnType<typeof createRuleDiscoveryCache>;
} = {
projectRoot,
targetFile: targetPath,
cache: discoveryCache,
};
if (disabledSources !== undefined) {
findOptions.disabledSources = disabledSources;
}
const candidates = findRuleCandidates(findOptions);
const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001");
const cacheKey = dynamicTargetCacheKey(targetPath);
fingerprints.push({
targetPath,
cacheKey,
fingerprint: hashContent(
[
"v1",
config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","),
projectRoot ?? "",
cacheKey,
candidateFingerprint,
].join("\u0000"),
),
});
}
return fingerprints;
}
function fingerprintCandidate(candidate: RuleCandidate): string {
return [
candidate.realPath,
candidate.relativePath,
candidate.source,
candidate.isGlobal ? "global" : "project",
candidate.isSingleFile ? "single" : "multi",
String(candidate.distance),
fileFingerprint(candidate.path),
].join("\u0000");
}
function fileFingerprint(filePath: string): string {
try {
const stats = statSync(filePath, { bigint: true });
return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`;
} catch {
return "missing";
}
}
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
if (config.enabledSources === "auto") {
return undefined;
}
const enabledSources = new Set(config.enabledSources);
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
}
function dynamicTargetCacheKey(targetPath: string): string {
return toPosixPath(resolve(targetPath));
}
@@ -0,0 +1,14 @@
export type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
export function formatAdditionalContextOutput(
eventName: ContextInjectionHookEventName,
additionalContext: string,
): string {
if (additionalContext.trim().length === 0) return "";
return `${JSON.stringify({
hookSpecificOutput: {
hookEventName: eventName,
additionalContext,
},
})}\n`;
}
@@ -0,0 +1,29 @@
import { isAbsolute, relative, resolve } from "node:path";
export function displayPath(cwd: string, filePath: string): string {
const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;
return toPosixPath(rel);
}
export function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const childRelativePath = relative(parentPath, resolve(childPath));
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
}
export function toPosixPath(path: string): string {
return path.replaceAll("\\", "/");
}
export function uniqueStrings(values: ReadonlyArray<string>): string[] {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
for (const value of values) {
if (seenValues.has(value)) {
continue;
}
seenValues.add(value);
uniqueValues.push(value);
}
return uniqueValues;
}
@@ -0,0 +1,9 @@
import type { PiRulesConfig } from "./rules/types.js";
export function withPostCompactBudget(config: PiRulesConfig): PiRulesConfig {
return {
...config,
maxRuleChars: Math.min(config.maxRuleChars, config.postCompactMaxRuleChars),
maxResultChars: Math.min(config.maxResultChars, config.postCompactMaxResultChars),
};
}
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { configFromEnvironment } from "./config.js";
import { createEngine } from "./rules/engine.js";
import { findRuleCandidates } from "./rules/finder.js";
import { findProjectRoot } from "./rules/project-root.js";
interface RulesEngineFactoryOptions {
env?: NodeJS.ProcessEnv;
}
export function createRulesEngine(options: RulesEngineFactoryOptions, config = configFromEnvironment(options.env)) {
return createEngine(config, {
findCandidates: findRuleCandidates,
findProjectRoot,
readFile: (path) => {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
},
});
}
@@ -92,6 +92,10 @@ export const DEFAULT_MAX_SCAN_FILES = 1000;
*/
export const DEFAULT_MAX_RESULT_CHARS = 40000;
export const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 6000;
export const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 12000;
/**
* Truncation marker template. `{path}` is replaced with the relative path.
*/
@@ -12,6 +12,8 @@ import {
import {
DEFAULT_MAX_RESULT_CHARS,
DEFAULT_MAX_RULE_CHARS,
DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
PROJECT_SINGLE_FILES,
SOURCE_PRIORITY,
} from "./constants.js";
@@ -74,6 +76,8 @@ export function defaultConfig(): PiRulesConfig {
mode: "both",
maxRuleChars: DEFAULT_MAX_RULE_CHARS,
maxResultChars: DEFAULT_MAX_RESULT_CHARS,
postCompactMaxRuleChars: DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
postCompactMaxResultChars: DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
enabledSources: "auto",
};
}
@@ -114,6 +114,8 @@ export interface PiRulesConfig {
mode: "static" | "dynamic" | "both" | "off";
maxRuleChars: number;
maxResultChars: number;
postCompactMaxRuleChars: number;
postCompactMaxResultChars: number;
enabledSources: RuleSource[] | "auto";
}
@@ -0,0 +1,44 @@
import type { LoadedRule } from "./rules/types.js";
import type { TranscriptSearchOptions } from "./transcript-search.js";
import { readTranscriptSearchText } from "./transcript-search.js";
export 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, options);
if (transcriptText === null) {
return [...rules];
}
const pendingRules: LoadedRule[] = [];
for (const rule of rules) {
if (isRuleAlreadyInTranscript(rule, transcriptText)) {
markInjected(rule);
continue;
}
pendingRules.push(rule);
}
return pendingRules;
}
function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean {
const bodyNeedle = rule.body.trim().slice(0, 2_000);
if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) {
return false;
}
const markers = [
`Instructions from: ${rule.path}`,
`Instructions from: ${rule.realPath}`,
rule.relativePath.length === 0 ? null : rule.relativePath,
].filter((marker): marker is string => marker !== null);
return markers.some((marker) => transcriptText.includes(marker));
}