feat(omo-codex): batch 102 (19 files)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
||||
|
||||
import {
|
||||
type CodexPostCompactInput,
|
||||
type CodexPostToolUseInput,
|
||||
type CodexRulesHookOptions,
|
||||
type CodexSessionStartInput,
|
||||
type CodexUserPromptSubmitInput,
|
||||
runPostCompactHook,
|
||||
runPostToolUseHook,
|
||||
runSessionStartHook,
|
||||
runUserPromptSubmitHook,
|
||||
} from "./codex-hook.js";
|
||||
|
||||
const command = process.argv[2];
|
||||
const subcommand = process.argv[3];
|
||||
type HookCliEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse" | "PostCompact";
|
||||
|
||||
if (command === "hook" && subcommand === "session-start") {
|
||||
await runHookCli("SessionStart");
|
||||
} else if (command === "hook" && subcommand === "user-prompt-submit") {
|
||||
await runHookCli("UserPromptSubmit");
|
||||
} else if (command === "hook" && subcommand === "post-tool-use") {
|
||||
await runHookCli("PostToolUse");
|
||||
} else if (command === "hook" && subcommand === "post-compact") {
|
||||
await runHookCli("PostCompact");
|
||||
} else {
|
||||
process.stderr.write("Usage: omo-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function runHookCli(eventName: HookCliEventName): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
if (raw.trim().length === 0) return;
|
||||
const parsed = parseHookInput(raw);
|
||||
if (!parsed) return;
|
||||
const pluginDataRoot = process.env["PLUGIN_DATA"];
|
||||
const options: CodexRulesHookOptions = pluginDataRoot === undefined ? {} : { pluginDataRoot };
|
||||
const output = await runHook(eventName, parsed, options);
|
||||
if (output.length > 0) {
|
||||
processStdout.write(output);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHook(eventName: HookCliEventName, parsed: unknown, options: CodexRulesHookOptions): Promise<string> {
|
||||
switch (eventName) {
|
||||
case "SessionStart":
|
||||
return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : "";
|
||||
case "UserPromptSubmit":
|
||||
return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : "";
|
||||
case "PostToolUse":
|
||||
return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : "";
|
||||
case "PostCompact":
|
||||
return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseHookInput(raw: string): unknown | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexSessionStartInput(value: unknown): value is CodexSessionStartInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "SessionStart" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["source"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "UserPromptSubmit" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["prompt"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostToolUse" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["tool_name"] === "string" &&
|
||||
typeof value["tool_use_id"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexPostCompactInput(value: unknown): value is CodexPostCompactInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostCompact" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
(value["trigger"] === "manual" || value["trigger"] === "auto")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringOrNull(value: unknown): value is string | null {
|
||||
return typeof value === "string" || value === null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
processStdin.setEncoding("utf8");
|
||||
processStdin.on("data", (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
processStdin.once("error", reject);
|
||||
processStdin.once("end", () => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface CodexRulesHookOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pluginDataRoot?: string;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { CodexRulesHookOptions } from "./codex-hook-options.js";
|
||||
import { configFromEnvironment } from "./config.js";
|
||||
import { hasContextPressureMarker, transcriptHasContextPressureMarker } from "./context-pressure.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 {
|
||||
claimPostCompactPending,
|
||||
clearSessionState,
|
||||
hasPostCompactPending,
|
||||
hydrateEngineState,
|
||||
isPostCompactRecoveryInProgress,
|
||||
markSessionCompacted,
|
||||
persistEngineState,
|
||||
sessionCachePath,
|
||||
} from "./persistent-cache.js";
|
||||
import { withPostCompactBudget } from "./post-compact-budget.js";
|
||||
import { claimedPostCompactKind, shouldSkipPostCompactClaim } from "./post-compact-claim.js";
|
||||
import { createRulesEngine } from "./rules-engine-factory.js";
|
||||
import { runStaticInjection } from "./static-injection.js";
|
||||
import { extractCodexToolPaths } from "./tool-paths.js";
|
||||
import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js";
|
||||
|
||||
export type { CodexRulesHookOptions } from "./codex-hook-options.js";
|
||||
|
||||
export type CodexSessionStartInput = {
|
||||
session_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "SessionStart";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
source: "startup" | "resume" | "clear" | "compact";
|
||||
};
|
||||
|
||||
export type CodexUserPromptSubmitInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "UserPromptSubmit";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type CodexPostToolUseInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostToolUse";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_response: unknown;
|
||||
tool_use_id: string;
|
||||
};
|
||||
|
||||
export type CodexPostCompactInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostCompact";
|
||||
model: string;
|
||||
trigger: "manual" | "auto";
|
||||
};
|
||||
|
||||
export async function runSessionStartHook(
|
||||
input: CodexSessionStartInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
if (input.source === "clear") {
|
||||
clearSessionState(cachePath);
|
||||
} else if (input.source !== "resume" && input.source !== "compact" && !hasPostCompactPending(cachePath)) {
|
||||
clearSessionState(cachePath);
|
||||
}
|
||||
const postCompactClaim = input.source === "clear" ? "not-pending" : claimPostCompactPending(cachePath, "static");
|
||||
const completedPostCompactKind =
|
||||
claimedPostCompactKind(postCompactClaim, "static") ??
|
||||
(input.source === "compact" && postCompactClaim === "not-pending" ? "static" : undefined);
|
||||
if (
|
||||
shouldSkipPostCompactClaim(
|
||||
postCompactClaim,
|
||||
input.source === "compact" && isPostCompactRecoveryInProgress(cachePath, "static"),
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const transcriptPath = input.source === "clear" ? null : input.transcript_path;
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
transcriptPath,
|
||||
"SessionStart",
|
||||
cachePath,
|
||||
options,
|
||||
completedPostCompactKind,
|
||||
{ latestCompactedReplacementOnly: completedPostCompactKind !== undefined },
|
||||
input.model,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPostCompactHook(
|
||||
input: CodexPostCompactInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot));
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function runUserPromptSubmitHook(
|
||||
input: CodexUserPromptSubmitInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
if (hasContextPressureMarker(input.prompt)) {
|
||||
return "";
|
||||
}
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
const postCompactClaim = claimPostCompactPending(cachePath, "static");
|
||||
if (postCompactClaim === "not-pending" && transcriptHasContextPressureMarker(input.transcript_path)) {
|
||||
return "";
|
||||
}
|
||||
const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "static");
|
||||
if (shouldSkipPostCompactClaim(postCompactClaim, isPostCompactRecoveryInProgress(cachePath, "static"))) {
|
||||
return "";
|
||||
}
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
input.transcript_path,
|
||||
"UserPromptSubmit",
|
||||
cachePath,
|
||||
options,
|
||||
completedPostCompactKind,
|
||||
{ latestCompactedReplacementOnly: completedPostCompactKind !== undefined },
|
||||
input.model,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPostToolUseHook(
|
||||
input: CodexPostToolUseInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const debugTimer = createHookDebugTimer("PostToolUse");
|
||||
const config = configFromEnvironment(options.env);
|
||||
debugTimer.lap("config", { disabled: config.disabled, mode: config.mode });
|
||||
if (config.disabled || config.mode === "off" || config.mode === "static") {
|
||||
debugTimer.done({ outputBytes: 0, reason: "disabled" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const targetPaths = extractCodexToolPaths(input, input.cwd);
|
||||
debugTimer.lap("extract", {
|
||||
targets: targetPaths.length,
|
||||
uniqueTargets: uniqueStrings(targetPaths).length,
|
||||
tool: input.tool_name,
|
||||
});
|
||||
const firstTargetPath = targetPaths[0];
|
||||
if (firstTargetPath === undefined) {
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-target" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
const postCompactClaim = claimPostCompactPending(cachePath, "dynamic");
|
||||
if (postCompactClaim === "not-pending" && transcriptHasContextPressureMarker(input.transcript_path)) {
|
||||
debugTimer.done({ outputBytes: 0, reason: "context-pressure-transcript" });
|
||||
return "";
|
||||
}
|
||||
const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "dynamic");
|
||||
if (shouldSkipPostCompactClaim(postCompactClaim, isPostCompactRecoveryInProgress(cachePath, "dynamic"))) {
|
||||
debugTimer.done({ outputBytes: 0, reason: "post-compact-recovery-in-progress" });
|
||||
return "";
|
||||
}
|
||||
const engine = createRulesEngine(
|
||||
options,
|
||||
completedPostCompactKind !== undefined
|
||||
? withPostCompactBudget(config, { model: input.model, transcriptPath: input.transcript_path })
|
||||
: config,
|
||||
);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
debugTimer.lap("hydrate", {
|
||||
dynamicDedupScopes: engine.state.dynamicDedup.size,
|
||||
dynamicTargetFingerprints: engine.state.dynamicTargetFingerprints.size,
|
||||
staticDedup: engine.state.staticDedup.size,
|
||||
});
|
||||
const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config);
|
||||
debugTimer.lap("fingerprint", { fingerprints: dynamicTargetFingerprints.length });
|
||||
const pendingTargetFingerprints = dynamicTargetFingerprints.filter(
|
||||
(target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint,
|
||||
);
|
||||
debugTimer.lap("pending", { pending: pendingTargetFingerprints.length });
|
||||
if (pendingTargetFingerprints.length === 0) {
|
||||
persistEngineState(engine, cachePath, completedPostCompactKind);
|
||||
debugTimer.lap("persist", { reason: "no-pending" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-pending" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const loaded = engine.loadDynamicRules(
|
||||
input.cwd,
|
||||
pendingTargetFingerprints.map((target) => target.targetPath),
|
||||
);
|
||||
debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length });
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)),
|
||||
input.transcript_path,
|
||||
(rule) => {
|
||||
engine.markDynamicInjected(rule);
|
||||
},
|
||||
{ latestCompactedReplacementOnly: completedPostCompactKind !== undefined },
|
||||
);
|
||||
debugTimer.lap("filter", { rules: rules.length });
|
||||
for (const target of pendingTargetFingerprints) {
|
||||
engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint);
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, completedPostCompactKind);
|
||||
debugTimer.lap("persist", { reason: "no-rules" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-rules" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath;
|
||||
const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath));
|
||||
debugTimer.lap("format", { blockChars: block.length, rules: rules.length });
|
||||
for (const rule of rules) {
|
||||
engine.markDynamicInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, completedPostCompactKind);
|
||||
debugTimer.lap("persist", { reason: "emit" });
|
||||
const output = formatAdditionalContextOutput("PostToolUse", block);
|
||||
debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" });
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
||||
import { defaultConfig } from "./rules/engine.js";
|
||||
import type { PiRulesConfig, RuleSource } from "./rules/types.js";
|
||||
|
||||
export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig {
|
||||
const config = defaultConfig();
|
||||
const disableBundledRules = isTruthy(firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED"));
|
||||
config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED"));
|
||||
config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode;
|
||||
config.maxRuleChars =
|
||||
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")) ??
|
||||
config.maxRuleChars;
|
||||
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,
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
function firstEnv(env: NodeJS.ProcessEnv, ...names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = env[name];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTruthy(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseMode(value: string | undefined): PiRulesConfig["mode"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "static":
|
||||
case "dynamic":
|
||||
case "both":
|
||||
case "off":
|
||||
return normalized;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseEnabledSources(value: string | undefined, disableBundledRules: boolean): RuleSource[] | "auto" {
|
||||
if (value === undefined || value.trim().toLowerCase() === "auto") {
|
||||
return disableBundledRules ? sourcesWithoutBundledRules() : "auto";
|
||||
}
|
||||
|
||||
const sources: RuleSource[] = [];
|
||||
for (const rawSource of value.split(",")) {
|
||||
const source = toRuleSource(rawSource.trim());
|
||||
if (source === null) {
|
||||
continue;
|
||||
}
|
||||
sources.push(source);
|
||||
}
|
||||
const enabledSources = disableBundledRules ? sources.filter((source) => source !== "plugin-bundled") : sources;
|
||||
return enabledSources.length > 0 || sources.length > 0 ? enabledSources : "auto";
|
||||
}
|
||||
|
||||
function sourcesWithoutBundledRules(): RuleSource[] {
|
||||
return [...SOURCE_PRIORITY.keys()].filter((source) => source !== "plugin-bundled");
|
||||
}
|
||||
|
||||
function toRuleSource(value: string): RuleSource | null {
|
||||
switch (value) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
case "plugin-bundled":
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const CONTEXT_PRESSURE_MARKERS = [
|
||||
"context compacted",
|
||||
"context_length_exceeded",
|
||||
"skill descriptions were shortened",
|
||||
"context_too_large",
|
||||
"codex ran out of room in the model's context window",
|
||||
"your input exceeds the context window",
|
||||
"long threads and multiple compactions",
|
||||
] as const;
|
||||
|
||||
export function hasContextPressureMarker(text: string): boolean {
|
||||
const normalizedText = text.toLowerCase();
|
||||
return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker));
|
||||
}
|
||||
|
||||
export function transcriptHasContextPressureMarker(transcriptPath: string | null | undefined): boolean {
|
||||
if (transcriptPath === undefined || transcriptPath === null) return false;
|
||||
try {
|
||||
return hasContextPressureMarker(readFileSync(transcriptPath, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { debuglog } from "node:util";
|
||||
|
||||
type DebugFieldValue = boolean | number | string | null;
|
||||
|
||||
type DebugFields = Record<string, DebugFieldValue>;
|
||||
|
||||
const debug = debuglog("codex-rules");
|
||||
const noopTimer: HookDebugTimer = {
|
||||
lap: () => {},
|
||||
done: () => {},
|
||||
};
|
||||
|
||||
export interface HookDebugTimer {
|
||||
lap(phase: string, fields?: DebugFields): void;
|
||||
done(fields?: DebugFields): void;
|
||||
}
|
||||
|
||||
export function createHookDebugTimer(hookName: string): HookDebugTimer {
|
||||
if (!debug.enabled) {
|
||||
return noopTimer;
|
||||
}
|
||||
|
||||
const startMs = performance.now();
|
||||
let lastMs = startMs;
|
||||
|
||||
return {
|
||||
lap: (phase, fields = {}) => {
|
||||
const nowMs = performance.now();
|
||||
writeDebugLine(hookName, phase, nowMs - lastMs, nowMs - startMs, fields);
|
||||
lastMs = nowMs;
|
||||
},
|
||||
done: (fields = {}) => {
|
||||
const nowMs = performance.now();
|
||||
writeDebugLine(hookName, "done", nowMs - lastMs, nowMs - startMs, fields);
|
||||
lastMs = nowMs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDebugLine(
|
||||
hookName: string,
|
||||
phase: string,
|
||||
durationMs: number,
|
||||
totalMs: number,
|
||||
fields: DebugFields,
|
||||
): void {
|
||||
debug(
|
||||
"%s phase=%s ms=%s total_ms=%s%s",
|
||||
hookName,
|
||||
phase,
|
||||
durationMs.toFixed(3),
|
||||
totalMs.toFixed(3),
|
||||
formatFields(fields),
|
||||
);
|
||||
}
|
||||
|
||||
function formatFields(fields: DebugFields): string {
|
||||
const entries = Object.entries(fields);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`;
|
||||
}
|
||||
@@ -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,19 @@
|
||||
export type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
|
||||
|
||||
export function formatAdditionalContextOutput(
|
||||
eventName: ContextInjectionHookEventName,
|
||||
additionalContext: string,
|
||||
): string {
|
||||
const normalizedContext = normalizeAdditionalContext(additionalContext);
|
||||
if (normalizedContext.length === 0) return "";
|
||||
return `${JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: eventName,
|
||||
additionalContext: normalizedContext,
|
||||
},
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
function normalizeAdditionalContext(additionalContext: string): string {
|
||||
return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
||||
}
|
||||
@@ -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,234 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
type PostCompactPendingKind,
|
||||
type PostCompactPendingState,
|
||||
postCompactKindState,
|
||||
postCompactPendingKinds,
|
||||
postCompactRecoveringKinds,
|
||||
} from "./post-compact-state.js";
|
||||
import type { Engine } from "./rules/engine.js";
|
||||
import { SESSION_STATE_LOCK_CONTENDED, withSessionStateLock } from "./session-state-lock.js";
|
||||
|
||||
export type PostCompactClaimResult = "claimed" | "not-pending" | "contended";
|
||||
|
||||
interface SerializedSessionState {
|
||||
staticDedup: string[];
|
||||
dynamicDedup: Record<string, string[]>;
|
||||
dynamicTargetFingerprints?: Record<string, string>;
|
||||
postCompactPending?: PostCompactPendingState;
|
||||
postCompactRecovering?: PostCompactPendingState;
|
||||
compacted?: boolean;
|
||||
}
|
||||
|
||||
export function hydrateEngineState(engine: Engine, cachePath: string): void {
|
||||
const state = readSessionState(cachePath);
|
||||
engine.state.staticDedup.clear();
|
||||
engine.state.dynamicDedup.clear();
|
||||
engine.state.dynamicTargetFingerprints.clear();
|
||||
|
||||
for (const key of state.staticDedup) {
|
||||
engine.state.staticDedup.add(key);
|
||||
}
|
||||
for (const [scope, keys] of Object.entries(state.dynamicDedup)) {
|
||||
engine.state.dynamicDedup.set(scope, new Set(keys));
|
||||
}
|
||||
for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) {
|
||||
engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistEngineState(
|
||||
engine: Engine,
|
||||
cachePath: string,
|
||||
completedPostCompactKind?: PostCompactPendingKind,
|
||||
): void {
|
||||
const currentState = readSessionState(cachePath);
|
||||
const dynamicDedup: Record<string, string[]> = {};
|
||||
for (const [scope, keys] of engine.state.dynamicDedup.entries()) {
|
||||
dynamicDedup[scope] = [...keys];
|
||||
}
|
||||
|
||||
const postCompactPending = nextPostCompactPending(currentState, completedPostCompactKind);
|
||||
const postCompactRecovering = nextPostCompactRecovering(currentState, completedPostCompactKind);
|
||||
writeSessionState(cachePath, {
|
||||
staticDedup: [...engine.state.staticDedup],
|
||||
dynamicDedup,
|
||||
dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()),
|
||||
...(postCompactPending === undefined ? {} : { postCompactPending }),
|
||||
...(postCompactRecovering === undefined ? {} : { postCompactRecovering }),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionState(cachePath: string): void {
|
||||
rmSync(cachePath, { force: true });
|
||||
}
|
||||
|
||||
export function markSessionCompacted(cachePath: string): void {
|
||||
const state = readSessionState(cachePath);
|
||||
writeSessionState(cachePath, {
|
||||
staticDedup: state.staticDedup,
|
||||
dynamicDedup: state.dynamicDedup,
|
||||
...(state.dynamicTargetFingerprints === undefined
|
||||
? {}
|
||||
: { dynamicTargetFingerprints: state.dynamicTargetFingerprints }),
|
||||
postCompactPending: { static: true, dynamic: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function hasPostCompactPending(cachePath: string): boolean {
|
||||
const state = readSessionState(cachePath);
|
||||
return postCompactPendingKinds(state).size > 0 || postCompactRecoveringKinds(state).size > 0;
|
||||
}
|
||||
|
||||
export function isPostCompactPending(cachePath: string, kind: PostCompactPendingKind): boolean {
|
||||
return postCompactPendingKinds(readSessionState(cachePath)).has(kind);
|
||||
}
|
||||
|
||||
export function claimPostCompactPending(cachePath: string, kind: PostCompactPendingKind): PostCompactClaimResult {
|
||||
const result = withSessionStateLock(cachePath, () => {
|
||||
const state = readSessionState(cachePath);
|
||||
const pendingKinds = postCompactPendingKinds(state);
|
||||
if (!pendingKinds.has(kind)) {
|
||||
return "not-pending";
|
||||
}
|
||||
|
||||
pendingKinds.delete(kind);
|
||||
const recoveringKinds = postCompactRecoveringKinds(state);
|
||||
recoveringKinds.add(kind);
|
||||
writeSessionState(cachePath, stateWithPostCompactKinds(state, pendingKinds, recoveringKinds));
|
||||
return "claimed";
|
||||
});
|
||||
return result === SESSION_STATE_LOCK_CONTENDED ? "contended" : result;
|
||||
}
|
||||
|
||||
export function isPostCompactRecoveryInProgress(cachePath: string, kind: PostCompactPendingKind): boolean {
|
||||
return postCompactRecoveringKinds(readSessionState(cachePath)).has(kind);
|
||||
}
|
||||
|
||||
export function completePostCompactRecovery(cachePath: string, kind: PostCompactPendingKind): void {
|
||||
withSessionStateLock(cachePath, () => {
|
||||
const state = readSessionState(cachePath);
|
||||
const pendingKinds = postCompactPendingKinds(state);
|
||||
const recoveringKinds = postCompactRecoveringKinds(state);
|
||||
recoveringKinds.delete(kind);
|
||||
writeSessionState(cachePath, stateWithPostCompactKinds(state, pendingKinds, recoveringKinds));
|
||||
});
|
||||
}
|
||||
|
||||
export function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string {
|
||||
const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules");
|
||||
return join(root, "sessions", `${safePathSegment(sessionId)}.json`);
|
||||
}
|
||||
|
||||
function readSessionState(cachePath: string): SerializedSessionState {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
||||
if (!isSerializedSessionState(parsed)) return emptyState();
|
||||
return parsed;
|
||||
} catch {
|
||||
return emptyState();
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionState(cachePath: string, state: SerializedSessionState): void {
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, `${JSON.stringify(state)}\n`);
|
||||
}
|
||||
|
||||
function emptyState(): SerializedSessionState {
|
||||
return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} };
|
||||
}
|
||||
|
||||
function nextPostCompactPending(
|
||||
state: SerializedSessionState,
|
||||
completedKind: PostCompactPendingKind | undefined,
|
||||
): PostCompactPendingState | undefined {
|
||||
const pendingKinds = postCompactPendingKinds(state);
|
||||
if (completedKind !== undefined) {
|
||||
pendingKinds.delete(completedKind);
|
||||
}
|
||||
|
||||
if (pendingKinds.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(pendingKinds.has("static") ? { static: true } : {}),
|
||||
...(pendingKinds.has("dynamic") ? { dynamic: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function nextPostCompactRecovering(
|
||||
state: SerializedSessionState,
|
||||
completedKind: PostCompactPendingKind | undefined,
|
||||
): PostCompactPendingState | undefined {
|
||||
const recoveringKinds = postCompactRecoveringKinds(state);
|
||||
if (completedKind !== undefined) {
|
||||
recoveringKinds.delete(completedKind);
|
||||
}
|
||||
|
||||
return postCompactKindState(recoveringKinds);
|
||||
}
|
||||
|
||||
function stateWithPostCompactKinds(
|
||||
state: SerializedSessionState,
|
||||
pendingKinds: ReadonlySet<PostCompactPendingKind>,
|
||||
recoveringKinds: ReadonlySet<PostCompactPendingKind>,
|
||||
): SerializedSessionState {
|
||||
const postCompactPending = postCompactKindState(pendingKinds);
|
||||
const postCompactRecovering = postCompactKindState(recoveringKinds);
|
||||
return {
|
||||
staticDedup: state.staticDedup,
|
||||
dynamicDedup: state.dynamicDedup,
|
||||
...(state.dynamicTargetFingerprints === undefined
|
||||
? {}
|
||||
: { dynamicTargetFingerprints: state.dynamicTargetFingerprints }),
|
||||
...(postCompactPending === undefined ? {} : { postCompactPending }),
|
||||
...(postCompactRecovering === undefined ? {} : { postCompactRecovering }),
|
||||
};
|
||||
}
|
||||
|
||||
function safePathSegment(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session";
|
||||
}
|
||||
|
||||
function isSerializedSessionState(value: unknown): value is SerializedSessionState {
|
||||
if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) {
|
||||
return false;
|
||||
}
|
||||
const staticDedup = value["staticDedup"];
|
||||
const dynamicDedup = value["dynamicDedup"];
|
||||
const dynamicTargetFingerprints = value["dynamicTargetFingerprints"];
|
||||
const postCompactPending = value["postCompactPending"];
|
||||
const postCompactRecovering = value["postCompactRecovering"];
|
||||
const compacted = value["compacted"];
|
||||
return (
|
||||
staticDedup.every((item) => typeof item === "string") &&
|
||||
Object.values(dynamicDedup).every(
|
||||
(item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string"),
|
||||
) &&
|
||||
(dynamicTargetFingerprints === undefined ||
|
||||
(isRecord(dynamicTargetFingerprints) &&
|
||||
Object.entries(dynamicTargetFingerprints).every(
|
||||
([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string",
|
||||
))) &&
|
||||
(postCompactPending === undefined || isPostCompactPendingState(postCompactPending)) &&
|
||||
(postCompactRecovering === undefined || isPostCompactPendingState(postCompactRecovering)) &&
|
||||
(compacted === undefined || typeof compacted === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function isPostCompactPendingState(value: unknown): value is PostCompactPendingState {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
(value["static"] === undefined || typeof value["static"] === "boolean") &&
|
||||
(value["dynamic"] === undefined || typeof value["dynamic"] === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { hasContextPressureMarker } from "./context-pressure.js";
|
||||
import type { PiRulesConfig } from "./rules/types.js";
|
||||
import { readTranscriptSearchText } from "./transcript-search.js";
|
||||
|
||||
export interface PostCompactBudgetContext {
|
||||
readonly model: string;
|
||||
readonly transcriptPath: string | null;
|
||||
}
|
||||
|
||||
interface ModelContextBudget {
|
||||
readonly slug: string;
|
||||
readonly contextWindowTokens: number;
|
||||
readonly effectivePercent: number;
|
||||
}
|
||||
|
||||
const DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95;
|
||||
const ESTIMATED_TRANSCRIPT_CHARS_PER_TOKEN = 3;
|
||||
const PROJECTED_INJECTION_CHARS_PER_TOKEN = 2;
|
||||
const POST_COMPACT_RESERVED_CONTEXT_PERCENT = 5;
|
||||
const POST_COMPACT_MIN_RESERVED_TOKENS = 8_000;
|
||||
const POST_COMPACT_MIN_GUIDE_CHARS = 500;
|
||||
const FALLBACK_CONTEXT_WINDOW_TOKENS = 200_000;
|
||||
const MODEL_CONTEXT_BUDGETS: readonly ModelContextBudget[] = [
|
||||
{ slug: "gpt-5.5", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT },
|
||||
{ slug: "gpt-5.4", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT },
|
||||
{ slug: "gpt-5.4-mini", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT },
|
||||
{ slug: "gpt-5.3-codex", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT },
|
||||
{ slug: "gpt-5.2", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT },
|
||||
{
|
||||
slug: "codex-auto-review",
|
||||
contextWindowTokens: 272_000,
|
||||
effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
},
|
||||
];
|
||||
|
||||
export function withPostCompactBudget(config: PiRulesConfig, context?: PostCompactBudgetContext): PiRulesConfig {
|
||||
const postCompactMaxResultChars = dynamicPostCompactMaxResultChars(context) ?? config.postCompactMaxResultChars;
|
||||
const maxResultChars = Math.min(config.maxResultChars, config.postCompactMaxResultChars, postCompactMaxResultChars);
|
||||
const maxRuleChars = Math.min(config.maxRuleChars, config.postCompactMaxRuleChars, maxResultChars);
|
||||
return {
|
||||
...config,
|
||||
maxRuleChars,
|
||||
maxResultChars,
|
||||
};
|
||||
}
|
||||
|
||||
function dynamicPostCompactMaxResultChars(context: PostCompactBudgetContext | undefined): number | undefined {
|
||||
if (context === undefined || context.transcriptPath === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const transcript = estimateTranscript(context.transcriptPath);
|
||||
if (transcript === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (hasContextPressureMarker(transcript.text)) {
|
||||
return POST_COMPACT_MIN_GUIDE_CHARS;
|
||||
}
|
||||
|
||||
const modelBudget = modelContextBudgetFor(context.model) ?? fallbackModelContextBudget();
|
||||
const effectiveContextWindow = Math.floor((modelBudget.contextWindowTokens * modelBudget.effectivePercent) / 100);
|
||||
const reservedTokens = Math.max(
|
||||
POST_COMPACT_MIN_RESERVED_TOKENS,
|
||||
Math.floor((effectiveContextWindow * POST_COMPACT_RESERVED_CONTEXT_PERCENT) / 100),
|
||||
);
|
||||
const injectableTokens = Math.max(0, effectiveContextWindow - reservedTokens - transcript.tokens);
|
||||
return Math.max(POST_COMPACT_MIN_GUIDE_CHARS, Math.floor(injectableTokens * PROJECTED_INJECTION_CHARS_PER_TOKEN));
|
||||
}
|
||||
|
||||
function modelContextBudgetFor(model: string): ModelContextBudget | undefined {
|
||||
const normalizedModel = model.trim().toLowerCase();
|
||||
for (const budget of MODEL_CONTEXT_BUDGETS) {
|
||||
if (
|
||||
normalizedModel === budget.slug ||
|
||||
normalizedModel.endsWith(`.${budget.slug}`) ||
|
||||
normalizedModel.endsWith(`/${budget.slug}`)
|
||||
) {
|
||||
return budget;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fallbackModelContextBudget(): ModelContextBudget {
|
||||
return {
|
||||
slug: "unknown",
|
||||
contextWindowTokens: FALLBACK_CONTEXT_WINDOW_TOKENS,
|
||||
effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
};
|
||||
}
|
||||
|
||||
function estimateTranscript(transcriptPath: string): { readonly text: string; readonly tokens: number } | undefined {
|
||||
const transcriptText =
|
||||
readTranscriptSearchText(transcriptPath, { latestCompactedReplacementOnly: true }) ??
|
||||
readTranscriptSearchText(transcriptPath);
|
||||
if (transcriptText === null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
text: transcriptText,
|
||||
tokens: Math.ceil(Buffer.byteLength(transcriptText, "utf8") / ESTIMATED_TRANSCRIPT_CHARS_PER_TOKEN),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PostCompactClaimResult } from "./persistent-cache.js";
|
||||
import type { PostCompactPendingKind } from "./post-compact-state.js";
|
||||
|
||||
export function claimedPostCompactKind<T extends PostCompactPendingKind>(
|
||||
result: PostCompactClaimResult,
|
||||
kind: T,
|
||||
): T | undefined {
|
||||
return result === "claimed" ? kind : undefined;
|
||||
}
|
||||
|
||||
export function shouldSkipPostCompactClaim(result: PostCompactClaimResult, recoveryInProgress: boolean): boolean {
|
||||
return result === "contended" || (result === "not-pending" && recoveryInProgress);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type PostCompactPendingKind = "static" | "dynamic";
|
||||
|
||||
export interface PostCompactPendingState {
|
||||
static?: boolean;
|
||||
dynamic?: boolean;
|
||||
}
|
||||
|
||||
export interface PostCompactStateFields {
|
||||
readonly postCompactPending?: PostCompactPendingState;
|
||||
readonly postCompactRecovering?: PostCompactPendingState;
|
||||
readonly compacted?: boolean;
|
||||
}
|
||||
|
||||
export function postCompactKindState(kinds: ReadonlySet<PostCompactPendingKind>): PostCompactPendingState | undefined {
|
||||
if (kinds.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(kinds.has("static") ? { static: true } : {}),
|
||||
...(kinds.has("dynamic") ? { dynamic: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function postCompactPendingKinds(state: PostCompactStateFields): Set<PostCompactPendingKind> {
|
||||
const pendingKinds = new Set<PostCompactPendingKind>();
|
||||
if (state.compacted === true || state.postCompactPending?.static === true) {
|
||||
pendingKinds.add("static");
|
||||
}
|
||||
if (state.compacted === true || state.postCompactPending?.dynamic === true) {
|
||||
pendingKinds.add("dynamic");
|
||||
}
|
||||
return pendingKinds;
|
||||
}
|
||||
|
||||
export function postCompactRecoveringKinds(state: PostCompactStateFields): Set<PostCompactPendingKind> {
|
||||
const recoveringKinds = new Set<PostCompactPendingKind>();
|
||||
if (state.postCompactRecovering?.static === true) {
|
||||
recoveringKinds.add("static");
|
||||
}
|
||||
if (state.postCompactRecovering?.dynamic === true) {
|
||||
recoveringKinds.add("dynamic");
|
||||
}
|
||||
return recoveringKinds;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export const SESSION_STATE_LOCK_CONTENDED = Symbol("session-state-lock-contended");
|
||||
|
||||
export type SessionStateLockResult<T> = T | typeof SESSION_STATE_LOCK_CONTENDED;
|
||||
|
||||
const LOCK_RETRY_COUNT = 20;
|
||||
const LOCK_RETRY_DELAY_MS = 5;
|
||||
const LOCK_SLEEP_VIEW = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
export function withSessionStateLock<T>(cachePath: string, callback: () => T): SessionStateLockResult<T> {
|
||||
const lockPath = `${cachePath}.lock`;
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt += 1) {
|
||||
try {
|
||||
mkdirSync(lockPath);
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
rmSync(lockPath, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "EEXIST") {
|
||||
sleepSync(LOCK_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return SESSION_STATE_LOCK_CONTENDED;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): unknown {
|
||||
if (!isRecord(error)) {
|
||||
return undefined;
|
||||
}
|
||||
return Reflect.get(error, "code");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sleepSync(milliseconds: number): void {
|
||||
Atomics.wait(LOCK_SLEEP_VIEW, 0, 0, milliseconds);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CodexRulesHookOptions } from "./codex-hook-options.js";
|
||||
import { configFromEnvironment } from "./config.js";
|
||||
import { formatAdditionalContextOutput } from "./hook-output.js";
|
||||
import { completePostCompactRecovery, hydrateEngineState, persistEngineState } from "./persistent-cache.js";
|
||||
import { withPostCompactBudget } from "./post-compact-budget.js";
|
||||
import { createRulesEngine } from "./rules-engine-factory.js";
|
||||
import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js";
|
||||
import type { TranscriptSearchOptions } from "./transcript-search.js";
|
||||
|
||||
export function runStaticInjection(
|
||||
cwd: string,
|
||||
transcriptPath: string | null,
|
||||
eventName: "SessionStart" | "UserPromptSubmit",
|
||||
cachePath: string,
|
||||
options: CodexRulesHookOptions,
|
||||
completedPostCompactChannel?: "static",
|
||||
transcriptSearchOptions: TranscriptSearchOptions = {},
|
||||
model?: string,
|
||||
): string {
|
||||
const config = configFromEnvironment(options.env);
|
||||
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
||||
if (completedPostCompactChannel !== undefined) {
|
||||
completePostCompactRecovery(cachePath, completedPostCompactChannel);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const effectiveConfig =
|
||||
completedPostCompactChannel === undefined
|
||||
? config
|
||||
: withPostCompactBudget(config, { model: model ?? "", transcriptPath });
|
||||
const engine = createRulesEngine(options, effectiveConfig);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
engine.state.cwd = cwd;
|
||||
|
||||
const loaded = engine.loadStaticRules(cwd);
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule)),
|
||||
transcriptPath,
|
||||
(rule) => {
|
||||
engine.markStaticInjected(rule);
|
||||
},
|
||||
transcriptSearchOptions,
|
||||
);
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return "";
|
||||
}
|
||||
|
||||
const block = engine.formatStatic(rules);
|
||||
for (const rule of rules) {
|
||||
engine.markStaticInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return formatAdditionalContextOutput(eventName, block);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
export interface CodexPostToolUseLike {
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_response: unknown;
|
||||
}
|
||||
|
||||
const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]);
|
||||
const TRACKED_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"read_file",
|
||||
"mcp__filesystem__read_file",
|
||||
"mcp__filesystem__read_multiple_files",
|
||||
"mcp__filesystem__write_file",
|
||||
"mcp__filesystem__edit_file",
|
||||
"write",
|
||||
"edit",
|
||||
"multiedit",
|
||||
"multi_edit",
|
||||
"apply_patch",
|
||||
"bash",
|
||||
"shell_command",
|
||||
"exec_command",
|
||||
]);
|
||||
|
||||
export function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): string[] {
|
||||
const toolName = input.tool_name.toLowerCase();
|
||||
if (!TRACKED_TOOL_NAMES.has(toolName) || isFailedToolResponse(input.tool_response)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths = new Set<string>();
|
||||
const toolInput = isRecord(input.tool_input) ? input.tool_input : {};
|
||||
addCommonPathFields(paths, toolInput, cwd);
|
||||
addPatchPayloadPaths(paths, toolInput, cwd);
|
||||
addPatchRecordPaths(paths, toolInput["files"], cwd);
|
||||
addPatchRecordPaths(paths, toolInput["changes"], cwd);
|
||||
|
||||
if (COMMAND_TOOL_NAMES.has(toolName)) {
|
||||
const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd");
|
||||
const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd");
|
||||
addCommandPaths(paths, command, workdir === undefined ? cwd : resolvePath(cwd, workdir));
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function addCommonPathFields(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
|
||||
for (const key of ["path", "filePath", "file_path", "target", "targetPath", "target_path"]) {
|
||||
addPath(paths, input[key], cwd, false);
|
||||
}
|
||||
for (const key of ["paths", "filePaths", "file_paths"]) {
|
||||
addPathArray(paths, input[key], cwd, false);
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchPayloadPaths(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
|
||||
for (const key of ["input", "patch", "command", "cmd"]) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string") {
|
||||
addPatchHeaderPaths(paths, value, cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchHeaderPaths(paths: Set<string>, patch: string, cwd: string): void {
|
||||
for (const line of patch.split("\n")) {
|
||||
for (const prefix of ["*** Add File: ", "*** Update File: ", "*** Move to: "]) {
|
||||
if (line.startsWith(prefix)) {
|
||||
addPath(paths, line.slice(prefix.length).trim(), cwd, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchRecordPaths(paths: Set<string>, value: unknown, cwd: string): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
if (typeof item === "string") {
|
||||
addPath(paths, item, cwd, false);
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
addCommonPathFields(paths, item, cwd);
|
||||
for (const key of ["movePath", "move_path", "to", "from"]) {
|
||||
addPath(paths, item[key], cwd, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addCommandPaths(paths: Set<string>, command: string | undefined, cwd: string): void {
|
||||
if (command === undefined) return;
|
||||
for (const token of tokenizeShell(command)) {
|
||||
if (token.length === 0 || token.startsWith("-") || token.includes("*")) {
|
||||
continue;
|
||||
}
|
||||
addPath(paths, token, cwd, true);
|
||||
}
|
||||
}
|
||||
|
||||
function addPathArray(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
addPath(paths, item, cwd, mustExist);
|
||||
}
|
||||
}
|
||||
|
||||
function addPath(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
|
||||
if (typeof value !== "string" || value.length === 0 || looksLikeUrl(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resolvePath(cwd, value);
|
||||
if (mustExist && !isExistingFile(path)) {
|
||||
return;
|
||||
}
|
||||
paths.add(path);
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
|
||||
}
|
||||
|
||||
function isExistingFile(filePath: string): boolean {
|
||||
try {
|
||||
return existsSync(filePath) && statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeUrl(value: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
|
||||
}
|
||||
|
||||
function stringProperty(value: Record<string, unknown>, key: string): string | undefined {
|
||||
const property = value[key];
|
||||
return typeof property === "string" && property.length > 0 ? property : undefined;
|
||||
}
|
||||
|
||||
function tokenizeShell(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (const character of command) {
|
||||
if (escaped) {
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ((character === "'" || character === '"') && quote === null) {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (quote === character) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (quote === null && /\s/.test(character)) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += character;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFailedToolResponse(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user