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:
@@ -1,8 +1,8 @@
|
|||||||
import { readFileSync, statSync } from "node:fs";
|
|
||||||
import { isAbsolute, relative, resolve } from "node:path";
|
|
||||||
|
|
||||||
import { configFromEnvironment } from "./config.js";
|
import { configFromEnvironment } from "./config.js";
|
||||||
import { createHookDebugTimer } from "./debug-log.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 {
|
import {
|
||||||
clearSessionState,
|
clearSessionState,
|
||||||
hasPostCompactPending,
|
hasPostCompactPending,
|
||||||
@@ -12,18 +12,11 @@ import {
|
|||||||
persistEngineState,
|
persistEngineState,
|
||||||
sessionCachePath,
|
sessionCachePath,
|
||||||
} from "./persistent-cache.js";
|
} from "./persistent-cache.js";
|
||||||
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
import { withPostCompactBudget } from "./post-compact-budget.js";
|
||||||
import { createEngine } from "./rules/engine.js";
|
import { createRulesEngine } from "./rules-engine-factory.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 { extractCodexToolPaths } from "./tool-paths.js";
|
import { extractCodexToolPaths } from "./tool-paths.js";
|
||||||
|
import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js";
|
||||||
import type { TranscriptSearchOptions } from "./transcript-search.js";
|
import type { TranscriptSearchOptions } from "./transcript-search.js";
|
||||||
import { readTranscriptSearchText } from "./transcript-search.js";
|
|
||||||
|
|
||||||
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
|
|
||||||
|
|
||||||
export type CodexSessionStartInput = {
|
export type CodexSessionStartInput = {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -75,12 +68,6 @@ export interface CodexRulesHookOptions {
|
|||||||
pluginDataRoot?: string;
|
pluginDataRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DynamicTargetFingerprint {
|
|
||||||
targetPath: string;
|
|
||||||
cacheKey: string;
|
|
||||||
fingerprint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSessionStartHook(
|
export async function runSessionStartHook(
|
||||||
input: CodexSessionStartInput,
|
input: CodexSessionStartInput,
|
||||||
options: CodexRulesHookOptions = {},
|
options: CodexRulesHookOptions = {},
|
||||||
@@ -155,7 +142,7 @@ export async function runPostToolUseHook(
|
|||||||
|
|
||||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||||
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
|
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
|
||||||
const engine = createRulesEngine(options);
|
const engine = createRulesEngine(options, postCompactPending ? withPostCompactBudget(config) : config);
|
||||||
hydrateEngineState(engine, cachePath);
|
hydrateEngineState(engine, cachePath);
|
||||||
debugTimer.lap("hydrate", {
|
debugTimer.lap("hydrate", {
|
||||||
dynamicDedupScopes: engine.state.dynamicDedup.size,
|
dynamicDedupScopes: engine.state.dynamicDedup.size,
|
||||||
@@ -226,7 +213,8 @@ function runStaticInjection(
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = createRulesEngine(options);
|
const effectiveConfig = completedPostCompactChannel === undefined ? config : withPostCompactBudget(config);
|
||||||
|
const engine = createRulesEngine(options, effectiveConfig);
|
||||||
hydrateEngineState(engine, cachePath);
|
hydrateEngineState(engine, cachePath);
|
||||||
engine.state.cwd = cwd;
|
engine.state.cwd = cwd;
|
||||||
|
|
||||||
@@ -251,183 +239,3 @@ function runStaticInjection(
|
|||||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||||
return formatAdditionalContextOutput(eventName, block);
|
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 =
|
config.maxResultChars =
|
||||||
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ??
|
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ??
|
||||||
config.maxResultChars;
|
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(
|
config.enabledSources = parseEnabledSources(
|
||||||
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
|
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
|
||||||
disableBundledRules,
|
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_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.
|
* Truncation marker template. `{path}` is replaced with the relative path.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_MAX_RESULT_CHARS,
|
DEFAULT_MAX_RESULT_CHARS,
|
||||||
DEFAULT_MAX_RULE_CHARS,
|
DEFAULT_MAX_RULE_CHARS,
|
||||||
|
DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
|
||||||
|
DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
|
||||||
PROJECT_SINGLE_FILES,
|
PROJECT_SINGLE_FILES,
|
||||||
SOURCE_PRIORITY,
|
SOURCE_PRIORITY,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
@@ -74,6 +76,8 @@ export function defaultConfig(): PiRulesConfig {
|
|||||||
mode: "both",
|
mode: "both",
|
||||||
maxRuleChars: DEFAULT_MAX_RULE_CHARS,
|
maxRuleChars: DEFAULT_MAX_RULE_CHARS,
|
||||||
maxResultChars: DEFAULT_MAX_RESULT_CHARS,
|
maxResultChars: DEFAULT_MAX_RESULT_CHARS,
|
||||||
|
postCompactMaxRuleChars: DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
|
||||||
|
postCompactMaxResultChars: DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
|
||||||
enabledSources: "auto",
|
enabledSources: "auto",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export interface PiRulesConfig {
|
|||||||
mode: "static" | "dynamic" | "both" | "off";
|
mode: "static" | "dynamic" | "both" | "off";
|
||||||
maxRuleChars: number;
|
maxRuleChars: number;
|
||||||
maxResultChars: number;
|
maxResultChars: number;
|
||||||
|
postCompactMaxRuleChars: number;
|
||||||
|
postCompactMaxResultChars: number;
|
||||||
enabledSources: RuleSource[] | "auto";
|
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));
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
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 CodexSessionStartInput,
|
||||||
|
runPostCompactHook,
|
||||||
|
runSessionStartHook,
|
||||||
|
runUserPromptSubmitHook,
|
||||||
|
} from "../src/codex-hook.js";
|
||||||
|
|
||||||
|
const tempDirectories: string[] = [];
|
||||||
|
const PROJECT_RULES_ENV = {
|
||||||
|
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
|
||||||
|
CODEX_RULES_MAX_RESULT_CHARS: "50000",
|
||||||
|
CODEX_RULES_MAX_RULE_CHARS: "30000",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of tempDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("codex rules post-compaction context budget", () => {
|
||||||
|
it("#given oversized project rules after compaction #when static rules re-inject #then output uses the post-compact budget", async () => {
|
||||||
|
// given
|
||||||
|
const { root, pluginData } = makeOversizedProject();
|
||||||
|
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
|
||||||
|
pluginDataRoot: pluginData,
|
||||||
|
env: PROJECT_RULES_ENV,
|
||||||
|
});
|
||||||
|
const firstContext = readAdditionalContext(firstOutput);
|
||||||
|
const transcriptPath = writeCompactedTranscript(root, "summary dropped injected rules");
|
||||||
|
await runPostCompactHook(
|
||||||
|
{ ...postCompactInput(root), transcript_path: transcriptPath },
|
||||||
|
{ pluginDataRoot: pluginData },
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
|
||||||
|
pluginDataRoot: pluginData,
|
||||||
|
env: PROJECT_RULES_ENV,
|
||||||
|
});
|
||||||
|
|
||||||
|
// then
|
||||||
|
const postCompactContext = readAdditionalContext(output);
|
||||||
|
expect(firstContext.length).toBeGreaterThan(20_000);
|
||||||
|
expect(postCompactContext.length).toBeLessThan(firstContext.length);
|
||||||
|
expect(postCompactContext.length).toBeLessThan(14_000);
|
||||||
|
expect(postCompactContext).toContain("[Rule truncated. Read full rule:");
|
||||||
|
expect(postCompactContext).toContain("Instructions from:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeOversizedProject(): { root: string; pluginData: string } {
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-post-compact-budget-project-"));
|
||||||
|
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-post-compact-budget-data-"));
|
||||||
|
tempDirectories.push(root, pluginData);
|
||||||
|
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
|
||||||
|
writeFileSync(path.join(root, "AGENTS.md"), `Project rule\n${"A".repeat(30_000)}`);
|
||||||
|
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(root, ".omo", "rules", "typescript.md"),
|
||||||
|
["---", 'globs: "**/*.ts"', "---", "", `TypeScript rule\n${"B".repeat(30_000)}`].join("\n"),
|
||||||
|
);
|
||||||
|
return { root, pluginData };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStartInput(root: string): CodexSessionStartInput {
|
||||||
|
return {
|
||||||
|
session_id: "session-post-compact-budget",
|
||||||
|
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-post-compact-budget",
|
||||||
|
turn_id: "turn-compact",
|
||||||
|
transcript_path: null,
|
||||||
|
cwd: root,
|
||||||
|
hook_event_name: "PostCompact",
|
||||||
|
model: "gpt-5.5",
|
||||||
|
trigger: "auto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function userPromptSubmitInput(root: string, transcriptPath: string): Parameters<typeof runUserPromptSubmitHook>[0] {
|
||||||
|
return {
|
||||||
|
session_id: "session-post-compact-budget",
|
||||||
|
turn_id: "turn-after-compact",
|
||||||
|
transcript_path: transcriptPath,
|
||||||
|
cwd: root,
|
||||||
|
hook_event_name: "UserPromptSubmit",
|
||||||
|
model: "gpt-5.5",
|
||||||
|
permission_mode: "default",
|
||||||
|
prompt: "continue",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCompactedTranscript(root: string, retainedText: string): string {
|
||||||
|
const transcriptPath = path.join(root, "transcript-compacted.jsonl");
|
||||||
|
writeFileSync(
|
||||||
|
transcriptPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
type: "compacted",
|
||||||
|
payload: {
|
||||||
|
message: "summary",
|
||||||
|
replacement_history: [{ type: "message", role: "user", content: retainedText }],
|
||||||
|
},
|
||||||
|
})}\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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user