diff --git a/packages/omo-codex/plugin/components/rules/src/cli.ts b/packages/omo-codex/plugin/components/rules/src/cli.ts new file mode 100644 index 000000000..dfa2ce2e5 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/cli.ts @@ -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 { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readStdin(): Promise { + 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); + }); + }); +} diff --git a/packages/omo-codex/plugin/components/rules/src/codex-hook-options.ts b/packages/omo-codex/plugin/components/rules/src/codex-hook-options.ts new file mode 100644 index 000000000..9cf4f6736 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/codex-hook-options.ts @@ -0,0 +1,4 @@ +export interface CodexRulesHookOptions { + env?: NodeJS.ProcessEnv; + pluginDataRoot?: string; +} diff --git a/packages/omo-codex/plugin/components/rules/src/codex-hook.ts b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts new file mode 100644 index 000000000..84d0f94cb --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts @@ -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 { + 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 { + markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot)); + return ""; +} + +export async function runUserPromptSubmitHook( + input: CodexUserPromptSubmitInput, + options: CodexRulesHookOptions = {}, +): Promise { + 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 { + 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; +} diff --git a/packages/omo-codex/plugin/components/rules/src/config.ts b/packages/omo-codex/plugin/components/rules/src/config.ts new file mode 100644 index 000000000..8db63e7ba --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/config.ts @@ -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; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/context-pressure.ts b/packages/omo-codex/plugin/components/rules/src/context-pressure.ts new file mode 100644 index 000000000..f880a0a01 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/context-pressure.ts @@ -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; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/debug-log.ts b/packages/omo-codex/plugin/components/rules/src/debug-log.ts new file mode 100644 index 000000000..cab97047b --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/debug-log.ts @@ -0,0 +1,65 @@ +import { performance } from "node:perf_hooks"; +import { debuglog } from "node:util"; + +type DebugFieldValue = boolean | number | string | null; + +type DebugFields = Record; + +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(" ")}`; +} diff --git a/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts b/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts new file mode 100644 index 000000000..32a8cb6c0 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/dynamic-target-fingerprints.ts @@ -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, + 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; + cache: ReturnType; + } = { + 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 | 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)); +} diff --git a/packages/omo-codex/plugin/components/rules/src/hook-output.ts b/packages/omo-codex/plugin/components/rules/src/hook-output.ts new file mode 100644 index 000000000..b60a94883 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/hook-output.ts @@ -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(); +} diff --git a/packages/omo-codex/plugin/components/rules/src/path-utils.ts b/packages/omo-codex/plugin/components/rules/src/path-utils.ts new file mode 100644 index 000000000..8f60449c3 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/path-utils.ts @@ -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[] { + const uniqueValues: string[] = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/packages/omo-codex/plugin/components/rules/src/persistent-cache.ts b/packages/omo-codex/plugin/components/rules/src/persistent-cache.ts new file mode 100644 index 000000000..9e24a0be0 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/persistent-cache.ts @@ -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; + dynamicTargetFingerprints?: Record; + 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 = {}; + 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, + recoveringKinds: ReadonlySet, +): 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts b/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts new file mode 100644 index 000000000..4815e5915 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts @@ -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), + }; +} diff --git a/packages/omo-codex/plugin/components/rules/src/post-compact-claim.ts b/packages/omo-codex/plugin/components/rules/src/post-compact-claim.ts new file mode 100644 index 000000000..834d92a67 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/post-compact-claim.ts @@ -0,0 +1,13 @@ +import type { PostCompactClaimResult } from "./persistent-cache.js"; +import type { PostCompactPendingKind } from "./post-compact-state.js"; + +export function claimedPostCompactKind( + 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); +} diff --git a/packages/omo-codex/plugin/components/rules/src/post-compact-state.ts b/packages/omo-codex/plugin/components/rules/src/post-compact-state.ts new file mode 100644 index 000000000..a04a4668a --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/post-compact-state.ts @@ -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): 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 { + const pendingKinds = new Set(); + 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 { + const recoveringKinds = new Set(); + if (state.postCompactRecovering?.static === true) { + recoveringKinds.add("static"); + } + if (state.postCompactRecovering?.dynamic === true) { + recoveringKinds.add("dynamic"); + } + return recoveringKinds; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts b/packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts new file mode 100644 index 000000000..484957fe2 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules-engine-factory.ts @@ -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; + } + }, + }); +} diff --git a/packages/omo-codex/plugin/components/rules/src/session-state-lock.ts b/packages/omo-codex/plugin/components/rules/src/session-state-lock.ts new file mode 100644 index 000000000..479ee60c6 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/session-state-lock.ts @@ -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 | 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(cachePath: string, callback: () => T): SessionStateLockResult { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sleepSync(milliseconds: number): void { + Atomics.wait(LOCK_SLEEP_VIEW, 0, 0, milliseconds); +} diff --git a/packages/omo-codex/plugin/components/rules/src/static-injection.ts b/packages/omo-codex/plugin/components/rules/src/static-injection.ts new file mode 100644 index 000000000..36d14c48a --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/static-injection.ts @@ -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); +} diff --git a/packages/omo-codex/plugin/components/rules/src/tool-paths.ts b/packages/omo-codex/plugin/components/rules/src/tool-paths.ts new file mode 100644 index 000000000..5974c4ae1 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/tool-paths.ts @@ -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(); + 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, input: Record, 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, input: Record, 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, 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, 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, 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, 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, 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, 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 { + 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" + ); +} diff --git a/packages/omo-codex/plugin/components/rules/src/transcript-rule-filter.ts b/packages/omo-codex/plugin/components/rules/src/transcript-rule-filter.ts new file mode 100644 index 000000000..c657f6e12 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/transcript-rule-filter.ts @@ -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, + 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)); +} diff --git a/packages/omo-codex/plugin/components/rules/src/transcript-search.ts b/packages/omo-codex/plugin/components/rules/src/transcript-search.ts new file mode 100644 index 000000000..94dd73ff3 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/transcript-search.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +}