feat(omo-codex): batch 102 (19 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:20 +09:00
parent 10e8671b5e
commit 20b0784811
19 changed files with 1596 additions and 0 deletions
@@ -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);
}