Skip to content

Commit 0625076

Browse files
Optimize rule injection caches (#8)
* perf(matcher): cache compiled glob matchers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * perf(engine): dedupe dynamic rule loading work Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(engine): preserve nested dynamic project roots Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(matcher): bound compiled glob cache Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: update unreleased changelog Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(engine): scope dynamic dedup by target path Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * test(extension): expect session compact handler Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * perf(engine): cache dynamic match decisions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 57c18e1 commit 0625076

11 files changed

Lines changed: 479 additions & 60 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Matcher cache reset and stats helpers for deterministic cache verification.
13+
14+
### Changed
15+
16+
- Glob matching now reuses a bounded compiled matcher cache instead of recompiling picomatch patterns for every file.
17+
- Dynamic rule loading now deduplicates repeated target paths and rule-file parsing work.
18+
1019
### Fixed
1120

1221
- 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.
1322
- Dynamic injection now skips rules already injected statically or already loaded by pi's native context loader.
23+
- Dynamic rule loading now preserves each target file's project root so nested projects load their nearest rules correctly.
1424

1525
## [0.1.0] - 2026-04-29
1626

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,16 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
116116
}
117117

118118
const loaded = engine.loadDynamicRules(ctx.cwd, targetPaths);
119-
const rules = loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule));
119+
const rules = loaded.rules.filter(
120+
(rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(firstTargetPath, rule),
121+
);
120122
if (rules.length === 0) {
121123
return undefined;
122124
}
123125

124126
const block = engine.formatDynamic(rules, displayPath(ctx.cwd, firstTargetPath));
125127
for (const rule of rules) {
126-
engine.markDynamicInjected(rule);
128+
engine.markDynamicInjected(firstTargetPath, rule);
127129
}
128130

129131
return { content: [...event.content, { type: "text", text: block }] };

src/rules/cache.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { LoadedRule, SessionState } from "./types.js";
22

3-
const DYNAMIC_SESSION_KEY = "__pi-rules-session__";
4-
53
export function createSessionState(cwd?: string): SessionState {
64
return { cwd, staticDedup: new Set(), dynamicDedup: new Map(), loadedRules: [], diagnostics: [] };
75
}
@@ -10,8 +8,8 @@ export function staticDedupKey(cwd: string, rulePath: string, contentHash: strin
108
return `${cwd}::${rulePath}::${contentHash}`;
119
}
1210

13-
export function dynamicDedupKey(rulePath: string, contentHash: string): string {
14-
return `${rulePath}::${contentHash}`;
11+
export function dynamicDedupKey(scopeKey: string, rulePath: string, contentHash: string): string {
12+
return `${scopeKey}::${rulePath}::${contentHash}`;
1513
}
1614

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

27-
export function markDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
28-
let keys = state.dynamicDedup.get(DYNAMIC_SESSION_KEY);
25+
export function markDynamicInjected(state: SessionState, scopeKey: string, rule: LoadedRule): boolean {
26+
let keys = state.dynamicDedup.get(scopeKey);
2927
if (keys === undefined) {
3028
keys = new Set();
31-
state.dynamicDedup.set(DYNAMIC_SESSION_KEY, keys);
29+
state.dynamicDedup.set(scopeKey, keys);
3230
}
3331

34-
const key = dynamicDedupKey(rule.realPath, rule.contentHash);
32+
const key = dynamicDedupKey(scopeKey, rule.realPath, rule.contentHash);
3533
if (keys.has(key)) {
3634
return false;
3735
}
@@ -44,8 +42,8 @@ export function isStaticInjected(state: SessionState, rule: LoadedRule): boolean
4442
return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash));
4543
}
4644

47-
export function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
48-
return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true;
45+
export function isDynamicInjected(state: SessionState, scopeKey: string, rule: LoadedRule): boolean {
46+
return state.dynamicDedup.get(scopeKey)?.has(dynamicDedupKey(scopeKey, rule.realPath, rule.contentHash)) === true;
4947
}
5048

5149
export function clearSession(state: SessionState): void {

src/rules/engine.ts

Lines changed: 153 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ import { sortCandidates } from "./ordering.js";
2222
import { parseRule } from "./parser.js";
2323
import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";
2424

25+
interface LoadedRuleContent {
26+
frontmatter: LoadedRule["frontmatter"];
27+
body: string;
28+
contentHash: string;
29+
diagnostic?: string;
30+
}
31+
32+
type CandidateProjectMembership = Map<string, boolean>;
33+
type DynamicMatchCache = Map<string, MatchReason | null>;
34+
35+
const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096;
36+
2537
export interface EngineDeps {
2638
findCandidates: (options: {
2739
projectRoot: string | null;
@@ -33,6 +45,7 @@ export interface EngineDeps {
3345
readFile: (path: string) => string | null;
3446
findProjectRoot: (startPath: string) => string | null;
3547
extractToolPaths: (event: ToolResultEvent, cwd: string) => string[];
48+
matchRule?: typeof matchRule;
3649
}
3750

3851
export interface Engine {
@@ -47,9 +60,9 @@ export interface Engine {
4760
formatDynamic(rules: ReadonlyArray<LoadedRule>, target: string): string;
4861
resetSession(cwd?: string): void;
4962
isStaticInjected(rule: LoadedRule): boolean;
50-
isDynamicInjected(rule: LoadedRule): boolean;
63+
isDynamicInjected(scopeKey: string, rule: LoadedRule): boolean;
5164
markStaticInjected(rule: LoadedRule): boolean;
52-
markDynamicInjected(rule: LoadedRule): boolean;
65+
markDynamicInjected(scopeKey: string, rule: LoadedRule): boolean;
5366
}
5467

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

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

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

101-
for (const targetFile of targetPaths) {
117+
for (const targetFile of uniqueStrings(targetPaths)) {
102118
const projectRoot = deps.findProjectRoot(targetFile);
103119
const candidates = deps.findCandidates({ projectRoot, targetFile, disabledSources });
104120

105121
for (const candidate of sortCandidates(candidates)) {
106-
const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot);
122+
const loadedRule = loadCandidate(
123+
candidate,
124+
deps,
125+
diagnostics,
126+
projectRoot,
127+
loadedRuleContent,
128+
projectMembership,
129+
);
107130
if (loadedRule === null) {
108131
continue;
109132
}
110133

111-
const matchResult = matchRule({
112-
frontmatter: loadedRule.frontmatter,
113-
isSingleFile: candidate.isSingleFile,
114-
pathBases: pathBasesForTarget(projectRoot, targetFile, candidate),
115-
});
134+
const matchReason = matchDynamicRuleCached(
135+
dynamicMatchCache,
136+
projectRoot,
137+
targetFile,
138+
candidate,
139+
loadedRule,
140+
deps.matchRule ?? matchRule,
141+
);
116142

117-
if (!matchResult.matched) {
143+
if (matchReason === null) {
118144
continue;
119145
}
120146

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

126152
seenRules.add(dedupKey);
127-
rules.push({ ...loadedRule, matchReason: matchResult.reason });
153+
rules.push({ ...loadedRule, matchReason });
128154
}
129155
}
130156

@@ -147,17 +173,73 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
147173
}),
148174
resetSession: (cwd) => {
149175
clearSession(state);
176+
dynamicMatchCache.clear();
150177
if (cwd !== undefined) {
151178
state.cwd = cwd;
152179
}
153180
},
154181
isStaticInjected: (rule) => isStaticInjectedInState(state, rule),
155-
isDynamicInjected: (rule) => isDynamicInjectedInState(state, rule),
182+
isDynamicInjected: (scopeKey, rule) => isDynamicInjectedInState(state, scopeKey, rule),
156183
markStaticInjected: (rule) => markStaticInjectedInState(state, rule),
157-
markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule),
184+
markDynamicInjected: (scopeKey, rule) => markDynamicInjectedInState(state, scopeKey, rule),
158185
};
159186
}
160187

188+
function matchDynamicRuleCached(
189+
cache: DynamicMatchCache,
190+
projectRoot: string | null,
191+
targetFile: string,
192+
candidate: RuleCandidate,
193+
loadedRule: LoadedRule,
194+
matchRuleImpl: typeof matchRule,
195+
): MatchReason | null {
196+
const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash);
197+
if (cache.has(cacheKey)) {
198+
const cachedReason = cache.get(cacheKey) ?? null;
199+
cache.delete(cacheKey);
200+
cache.set(cacheKey, cachedReason);
201+
return cachedReason;
202+
}
203+
204+
const matchResult = matchRuleImpl({
205+
frontmatter: loadedRule.frontmatter,
206+
isSingleFile: candidate.isSingleFile,
207+
pathBases: pathBasesForTarget(projectRoot, targetFile, candidate),
208+
});
209+
const reason = matchResult.matched ? matchResult.reason : null;
210+
setDynamicMatchCacheEntry(cache, cacheKey, reason);
211+
return reason;
212+
}
213+
214+
function setDynamicMatchCacheEntry(cache: DynamicMatchCache, cacheKey: string, reason: MatchReason | null): void {
215+
if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) {
216+
const oldestCacheKey = cache.keys().next().value;
217+
if (oldestCacheKey !== undefined) {
218+
cache.delete(oldestCacheKey);
219+
}
220+
}
221+
cache.set(cacheKey, reason);
222+
}
223+
224+
function dynamicMatchCacheKey(
225+
projectRoot: string | null,
226+
targetFile: string,
227+
candidate: RuleCandidate,
228+
contentHash: string,
229+
): string {
230+
return [
231+
projectRoot ?? "",
232+
toPosixPath(resolve(targetFile)),
233+
candidate.realPath,
234+
candidate.relativePath,
235+
candidate.source,
236+
candidate.isGlobal ? "global" : "project",
237+
candidate.isSingleFile ? "single" : "multi",
238+
String(candidate.distance),
239+
contentHash,
240+
].join("\0");
241+
}
242+
161243
function loadStaticCandidates(candidates: ReadonlyArray<RuleCandidate>, deps: EngineDeps, projectRoot: string | null) {
162244
const rules: LoadedRule[] = [];
163245
const diagnostics: RuleDiagnostic[] = [];
@@ -193,8 +275,10 @@ function loadCandidate(
193275
deps: EngineDeps,
194276
diagnostics: RuleDiagnostic[],
195277
projectRoot: string | null,
278+
loadedRuleContent?: Map<string, LoadedRuleContent | null>,
279+
projectMembership?: CandidateProjectMembership,
196280
): (LoadedRule & { matchReason: MatchReason }) | null {
197-
if (!isCandidateWithinProject(candidate, projectRoot)) {
281+
if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) {
198282
diagnostics.push({
199283
severity: "warning",
200284
source: candidate.path,
@@ -203,22 +287,48 @@ function loadCandidate(
203287
return null;
204288
}
205289

290+
const cachedContent = loadedRuleContent?.get(candidate.realPath);
291+
if (cachedContent !== undefined) {
292+
return loadedRuleFromContent(candidate, cachedContent, diagnostics);
293+
}
294+
206295
const content = deps.readFile(candidate.path);
207296
if (content === null) {
297+
loadedRuleContent?.set(candidate.realPath, null);
208298
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
209299
return null;
210300
}
211301

212302
const parsed = parseRule(content);
213-
if (parsed.diagnostic !== undefined) {
214-
diagnostics.push({ severity: "warning", source: candidate.path, message: parsed.diagnostic });
303+
const loadedContent = {
304+
frontmatter: parsed.frontmatter,
305+
body: parsed.body,
306+
contentHash: hashContent(parsed.body),
307+
diagnostic: parsed.diagnostic,
308+
} satisfies LoadedRuleContent;
309+
loadedRuleContent?.set(candidate.realPath, loadedContent);
310+
return loadedRuleFromContent(candidate, loadedContent, diagnostics);
311+
}
312+
313+
function loadedRuleFromContent(
314+
candidate: RuleCandidate,
315+
content: LoadedRuleContent | null,
316+
diagnostics: RuleDiagnostic[],
317+
): (LoadedRule & { matchReason: MatchReason }) | null {
318+
if (content === null) {
319+
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
320+
return null;
321+
}
322+
323+
if (content.diagnostic !== undefined) {
324+
diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic });
215325
}
216326

217327
return {
218328
...candidate,
219-
frontmatter: parsed.frontmatter,
220-
body: parsed.body,
221-
contentHash: hashContent(parsed.body),
329+
frontmatter: content.frontmatter,
330+
body: content.body,
331+
contentHash: content.contentHash,
222332
matchReason: { kind: "no-match" },
223333
};
224334
}
@@ -240,6 +350,26 @@ function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string
240350
return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath));
241351
}
242352

353+
function isCandidateWithinProjectCached(
354+
candidate: RuleCandidate,
355+
projectRoot: string | null,
356+
projectMembership: CandidateProjectMembership | undefined,
357+
): boolean {
358+
if (projectMembership === undefined) {
359+
return isCandidateWithinProject(candidate, projectRoot);
360+
}
361+
362+
const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`;
363+
const cached = projectMembership.get(cacheKey);
364+
if (cached !== undefined) {
365+
return cached;
366+
}
367+
368+
const isWithinProject = isCandidateWithinProject(candidate, projectRoot);
369+
projectMembership.set(cacheKey, isWithinProject);
370+
return isWithinProject;
371+
}
372+
243373
function staticMatchReason(rule: LoadedRule): MatchReason | null {
244374
if (rule.frontmatter.alwaysApply === true) {
245375
return "alwaysApply";
@@ -314,6 +444,10 @@ function toPosixPath(path: string): string {
314444
return path.replaceAll("\\", "/");
315445
}
316446

447+
function uniqueStrings(values: ReadonlyArray<string>): string[] {
448+
return [...new Set(values)];
449+
}
450+
317451
function storeLastLoad(
318452
state: SessionState,
319453
rules: ReadonlyArray<LoadedRule>,

0 commit comments

Comments
 (0)