Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Matcher cache reset and stats helpers for deterministic cache verification.

### Changed

- Glob matching now reuses a bounded compiled matcher cache instead of recompiling picomatch patterns for every file.
- Dynamic rule loading now deduplicates repeated target paths and rule-file parsing work.

### Fixed

- Dynamic rule injection now dedupes by rule across the session instead of per tool call, preventing repeated nested `AGENTS.md`/`CLAUDE.md` instruction blocks on subsequent reads.
- Dynamic injection now skips rules already injected statically or already loaded by pi's native context loader.
- Dynamic rule loading now preserves each target file's project root so nested projects load their nearest rules correctly.

## [0.1.0] - 2026-04-29

Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,16 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
}

const loaded = engine.loadDynamicRules(ctx.cwd, targetPaths);
const rules = loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule));
const rules = loaded.rules.filter(
(rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(firstTargetPath, rule),
);
if (rules.length === 0) {
return undefined;
}

const block = engine.formatDynamic(rules, displayPath(ctx.cwd, firstTargetPath));
for (const rule of rules) {
engine.markDynamicInjected(rule);
engine.markDynamicInjected(firstTargetPath, rule);
}

return { content: [...event.content, { type: "text", text: block }] };
Expand Down
18 changes: 8 additions & 10 deletions src/rules/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { LoadedRule, SessionState } from "./types.js";

const DYNAMIC_SESSION_KEY = "__pi-rules-session__";

export function createSessionState(cwd?: string): SessionState {
return { cwd, staticDedup: new Set(), dynamicDedup: new Map(), loadedRules: [], diagnostics: [] };
}
Expand All @@ -10,8 +8,8 @@ export function staticDedupKey(cwd: string, rulePath: string, contentHash: strin
return `${cwd}::${rulePath}::${contentHash}`;
}

export function dynamicDedupKey(rulePath: string, contentHash: string): string {
return `${rulePath}::${contentHash}`;
export function dynamicDedupKey(scopeKey: string, rulePath: string, contentHash: string): string {
return `${scopeKey}::${rulePath}::${contentHash}`;
}

export function markStaticInjected(state: SessionState, rule: LoadedRule): boolean {
Expand All @@ -24,14 +22,14 @@ export function markStaticInjected(state: SessionState, rule: LoadedRule): boole
return true;
}

export function markDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
let keys = state.dynamicDedup.get(DYNAMIC_SESSION_KEY);
export function markDynamicInjected(state: SessionState, scopeKey: string, rule: LoadedRule): boolean {
let keys = state.dynamicDedup.get(scopeKey);
if (keys === undefined) {
keys = new Set();
state.dynamicDedup.set(DYNAMIC_SESSION_KEY, keys);
state.dynamicDedup.set(scopeKey, keys);
}

const key = dynamicDedupKey(rule.realPath, rule.contentHash);
const key = dynamicDedupKey(scopeKey, rule.realPath, rule.contentHash);
if (keys.has(key)) {
return false;
}
Expand All @@ -44,8 +42,8 @@ export function isStaticInjected(state: SessionState, rule: LoadedRule): boolean
return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash));
}

export function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true;
export function isDynamicInjected(state: SessionState, scopeKey: string, rule: LoadedRule): boolean {
return state.dynamicDedup.get(scopeKey)?.has(dynamicDedupKey(scopeKey, rule.realPath, rule.contentHash)) === true;
}

export function clearSession(state: SessionState): void {
Expand Down
172 changes: 153 additions & 19 deletions src/rules/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ import { sortCandidates } from "./ordering.js";
import { parseRule } from "./parser.js";
import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";

interface LoadedRuleContent {
frontmatter: LoadedRule["frontmatter"];
body: string;
contentHash: string;
diagnostic?: string;
}

type CandidateProjectMembership = Map<string, boolean>;
type DynamicMatchCache = Map<string, MatchReason | null>;

const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096;

export interface EngineDeps {
findCandidates: (options: {
projectRoot: string | null;
Expand All @@ -33,6 +45,7 @@ export interface EngineDeps {
readFile: (path: string) => string | null;
findProjectRoot: (startPath: string) => string | null;
extractToolPaths: (event: ToolResultEvent, cwd: string) => string[];
matchRule?: typeof matchRule;
}

export interface Engine {
Expand All @@ -47,9 +60,9 @@ export interface Engine {
formatDynamic(rules: ReadonlyArray<LoadedRule>, target: string): string;
resetSession(cwd?: string): void;
isStaticInjected(rule: LoadedRule): boolean;
isDynamicInjected(rule: LoadedRule): boolean;
isDynamicInjected(scopeKey: string, rule: LoadedRule): boolean;
markStaticInjected(rule: LoadedRule): boolean;
markDynamicInjected(rule: LoadedRule): boolean;
markDynamicInjected(scopeKey: string, rule: LoadedRule): boolean;
}

const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/")));
Expand All @@ -66,6 +79,7 @@ export function defaultConfig(): PiRulesConfig {

export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
const state = createSessionState();
const dynamicMatchCache: DynamicMatchCache = new Map();

function loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
state.cwd = cwd;
Expand Down Expand Up @@ -96,25 +110,37 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
const rules: LoadedRule[] = [];
const diagnostics: RuleDiagnostic[] = [];
const seenRules = new Set<string>();
const loadedRuleContent = new Map<string, LoadedRuleContent | null>();
const projectMembership = new Map<string, boolean>();
const disabledSources = disabledSourcesFor(config);

for (const targetFile of targetPaths) {
for (const targetFile of uniqueStrings(targetPaths)) {
const projectRoot = deps.findProjectRoot(targetFile);
const candidates = deps.findCandidates({ projectRoot, targetFile, disabledSources });

for (const candidate of sortCandidates(candidates)) {
const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot);
const loadedRule = loadCandidate(
candidate,
deps,
diagnostics,
projectRoot,
loadedRuleContent,
projectMembership,
);
if (loadedRule === null) {
continue;
}

const matchResult = matchRule({
frontmatter: loadedRule.frontmatter,
isSingleFile: candidate.isSingleFile,
pathBases: pathBasesForTarget(projectRoot, targetFile, candidate),
});
const matchReason = matchDynamicRuleCached(
dynamicMatchCache,
projectRoot,
targetFile,
candidate,
loadedRule,
deps.matchRule ?? matchRule,
);

if (!matchResult.matched) {
if (matchReason === null) {
continue;
}

Expand All @@ -124,7 +150,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
}

seenRules.add(dedupKey);
rules.push({ ...loadedRule, matchReason: matchResult.reason });
rules.push({ ...loadedRule, matchReason });
}
}

Expand All @@ -147,17 +173,73 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
}),
resetSession: (cwd) => {
clearSession(state);
dynamicMatchCache.clear();
if (cwd !== undefined) {
state.cwd = cwd;
}
},
isStaticInjected: (rule) => isStaticInjectedInState(state, rule),
isDynamicInjected: (rule) => isDynamicInjectedInState(state, rule),
isDynamicInjected: (scopeKey, rule) => isDynamicInjectedInState(state, scopeKey, rule),
markStaticInjected: (rule) => markStaticInjectedInState(state, rule),
markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule),
markDynamicInjected: (scopeKey, rule) => markDynamicInjectedInState(state, scopeKey, rule),
};
}

function matchDynamicRuleCached(
cache: DynamicMatchCache,
projectRoot: string | null,
targetFile: string,
candidate: RuleCandidate,
loadedRule: LoadedRule,
matchRuleImpl: typeof matchRule,
): MatchReason | null {
const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash);
if (cache.has(cacheKey)) {
const cachedReason = cache.get(cacheKey) ?? null;
cache.delete(cacheKey);
cache.set(cacheKey, cachedReason);
return cachedReason;
}

const matchResult = matchRuleImpl({
frontmatter: loadedRule.frontmatter,
isSingleFile: candidate.isSingleFile,
pathBases: pathBasesForTarget(projectRoot, targetFile, candidate),
});
const reason = matchResult.matched ? matchResult.reason : null;
setDynamicMatchCacheEntry(cache, cacheKey, reason);
return reason;
}

function setDynamicMatchCacheEntry(cache: DynamicMatchCache, cacheKey: string, reason: MatchReason | null): void {
if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) {
const oldestCacheKey = cache.keys().next().value;
if (oldestCacheKey !== undefined) {
cache.delete(oldestCacheKey);
}
}
cache.set(cacheKey, reason);
}

function dynamicMatchCacheKey(
projectRoot: string | null,
targetFile: string,
candidate: RuleCandidate,
contentHash: string,
): string {
return [
projectRoot ?? "",
toPosixPath(resolve(targetFile)),
candidate.realPath,
candidate.relativePath,
candidate.source,
candidate.isGlobal ? "global" : "project",
candidate.isSingleFile ? "single" : "multi",
String(candidate.distance),
contentHash,
].join("\0");
}

function loadStaticCandidates(candidates: ReadonlyArray<RuleCandidate>, deps: EngineDeps, projectRoot: string | null) {
const rules: LoadedRule[] = [];
const diagnostics: RuleDiagnostic[] = [];
Expand Down Expand Up @@ -193,8 +275,10 @@ function loadCandidate(
deps: EngineDeps,
diagnostics: RuleDiagnostic[],
projectRoot: string | null,
loadedRuleContent?: Map<string, LoadedRuleContent | null>,
projectMembership?: CandidateProjectMembership,
): (LoadedRule & { matchReason: MatchReason }) | null {
if (!isCandidateWithinProject(candidate, projectRoot)) {
if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) {
diagnostics.push({
severity: "warning",
source: candidate.path,
Expand All @@ -203,22 +287,48 @@ function loadCandidate(
return null;
}

const cachedContent = loadedRuleContent?.get(candidate.realPath);
if (cachedContent !== undefined) {
return loadedRuleFromContent(candidate, cachedContent, diagnostics);
}

const content = deps.readFile(candidate.path);
if (content === null) {
loadedRuleContent?.set(candidate.realPath, null);
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
return null;
}

const parsed = parseRule(content);
if (parsed.diagnostic !== undefined) {
diagnostics.push({ severity: "warning", source: candidate.path, message: parsed.diagnostic });
const loadedContent = {
frontmatter: parsed.frontmatter,
body: parsed.body,
contentHash: hashContent(parsed.body),
diagnostic: parsed.diagnostic,
} satisfies LoadedRuleContent;
loadedRuleContent?.set(candidate.realPath, loadedContent);
return loadedRuleFromContent(candidate, loadedContent, diagnostics);
}

function loadedRuleFromContent(
candidate: RuleCandidate,
content: LoadedRuleContent | null,
diagnostics: RuleDiagnostic[],
): (LoadedRule & { matchReason: MatchReason }) | null {
if (content === null) {
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
return null;
}

if (content.diagnostic !== undefined) {
diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic });
}

return {
...candidate,
frontmatter: parsed.frontmatter,
body: parsed.body,
contentHash: hashContent(parsed.body),
frontmatter: content.frontmatter,
body: content.body,
contentHash: content.contentHash,
matchReason: { kind: "no-match" },
};
}
Expand All @@ -240,6 +350,26 @@ function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string
return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath));
}

function isCandidateWithinProjectCached(
candidate: RuleCandidate,
projectRoot: string | null,
projectMembership: CandidateProjectMembership | undefined,
): boolean {
if (projectMembership === undefined) {
return isCandidateWithinProject(candidate, projectRoot);
}

const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`;
const cached = projectMembership.get(cacheKey);
if (cached !== undefined) {
return cached;
}

const isWithinProject = isCandidateWithinProject(candidate, projectRoot);
projectMembership.set(cacheKey, isWithinProject);
return isWithinProject;
}

function staticMatchReason(rule: LoadedRule): MatchReason | null {
if (rule.frontmatter.alwaysApply === true) {
return "alwaysApply";
Expand Down Expand Up @@ -314,6 +444,10 @@ function toPosixPath(path: string): string {
return path.replaceAll("\\", "/");
}

function uniqueStrings(values: ReadonlyArray<string>): string[] {
return [...new Set(values)];
}

function storeLastLoad(
state: SessionState,
rules: ReadonlyArray<LoadedRule>,
Expand Down
Loading