Files
oh-my-opencode/packages/omo-codex/plugin/components/rules/src/codex-hook.ts
T
2026-05-28 14:44:14 +09:00

434 lines
13 KiB
TypeScript

import { readFileSync, statSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { configFromEnvironment } from "./config.js";
import { createHookDebugTimer } from "./debug-log.js";
import {
clearSessionState,
hasPostCompactPending,
hydrateEngineState,
isPostCompactPending,
markSessionCompacted,
persistEngineState,
sessionCachePath,
} from "./persistent-cache.js";
import { SOURCE_PRIORITY } from "./rules/constants.js";
import { createEngine } from "./rules/engine.js";
import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
import { hashContent } from "./rules/matcher.js";
import { sortCandidates } from "./rules/ordering.js";
import { findProjectRoot } from "./rules/project-root.js";
import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js";
import { extractCodexToolPaths } from "./tool-paths.js";
import type { TranscriptSearchOptions } from "./transcript-search.js";
import { readTranscriptSearchText } from "./transcript-search.js";
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
export type CodexSessionStartInput = {
session_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "SessionStart";
model: string;
permission_mode: string;
source: "startup" | "resume" | "clear";
};
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 interface CodexRulesHookOptions {
env?: NodeJS.ProcessEnv;
pluginDataRoot?: string;
}
interface DynamicTargetFingerprint {
targetPath: string;
cacheKey: string;
fingerprint: string;
}
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" && !hasPostCompactPending(cachePath)) {
clearSessionState(cachePath);
}
const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static");
const transcriptPath = input.source === "clear" ? null : input.transcript_path;
return runStaticInjection(
input.cwd,
transcriptPath,
"SessionStart",
cachePath,
options,
postCompactPending ? "static" : undefined,
{ latestCompactedReplacementOnly: postCompactPending },
);
}
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> {
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
const postCompactPending = isPostCompactPending(cachePath, "static");
return runStaticInjection(
input.cwd,
input.transcript_path,
"UserPromptSubmit",
cachePath,
options,
postCompactPending ? "static" : undefined,
{ latestCompactedReplacementOnly: postCompactPending },
);
}
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 postCompactPending = isPostCompactPending(cachePath, "dynamic");
const engine = createRulesEngine(options);
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, postCompactPending ? "dynamic" : undefined);
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: postCompactPending },
);
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, postCompactPending ? "dynamic" : undefined);
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, postCompactPending ? "dynamic" : undefined);
debugTimer.lap("persist", { reason: "emit" });
const output = formatAdditionalContextOutput("PostToolUse", block);
debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" });
return output;
}
function runStaticInjection(
cwd: string,
transcriptPath: string | null,
eventName: "SessionStart" | "UserPromptSubmit",
cachePath: string,
options: CodexRulesHookOptions,
completedPostCompactChannel?: "static",
transcriptSearchOptions: TranscriptSearchOptions = {},
): string {
const config = configFromEnvironment(options.env);
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
return "";
}
const engine = createRulesEngine(options);
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);
}
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("\\", "/");
}