fix(rules-injector): cache directory scan results per session
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { clearInjectedRules, loadInjectedRules } from "./storage";
|
import { clearInjectedRules, loadInjectedRules } from "./storage";
|
||||||
|
import { createRuleScanCache } from "./rule-scan-cache";
|
||||||
|
import type { RuleScanCache } from "./rule-scan-cache";
|
||||||
|
|
||||||
export type SessionInjectedRulesCache = {
|
export type SessionInjectedRulesCache = {
|
||||||
contentHashes: Set<string>;
|
contentHashes: Set<string>;
|
||||||
@@ -25,3 +27,29 @@ export function createSessionCacheStore(): {
|
|||||||
|
|
||||||
return { getSessionCache, clearSessionCache };
|
return { getSessionCache, clearSessionCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSessionRuleScanCacheStore(): {
|
||||||
|
getSessionRuleScanCache: (sessionID: string) => RuleScanCache;
|
||||||
|
clearSessionRuleScanCache: (sessionID: string) => void;
|
||||||
|
} {
|
||||||
|
const sessionCaches = new Map<string, RuleScanCache>();
|
||||||
|
|
||||||
|
function getSessionRuleScanCache(sessionID: string): RuleScanCache {
|
||||||
|
const existingCache = sessionCaches.get(sessionID);
|
||||||
|
if (existingCache) {
|
||||||
|
return existingCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = createRuleScanCache();
|
||||||
|
sessionCaches.set(sessionID, cache);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionRuleScanCache(sessionID: string): void {
|
||||||
|
const cache = sessionCaches.get(sessionID);
|
||||||
|
cache?.clear();
|
||||||
|
sessionCaches.delete(sessionID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getSessionRuleScanCache, clearSessionRuleScanCache };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
import { getRuleInjectionFilePath } from "./output-path";
|
import { getRuleInjectionFilePath } from "./output-path";
|
||||||
import { createSessionCacheStore } from "./cache";
|
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
||||||
import { createRuleInjectionProcessor } from "./injector";
|
import { createRuleInjectionProcessor } from "./injector";
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
interface ToolExecuteInput {
|
||||||
@@ -36,15 +36,23 @@ export function createRulesInjectorHook(
|
|||||||
) {
|
) {
|
||||||
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
||||||
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
|
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
|
||||||
|
const { getSessionRuleScanCache, clearSessionRuleScanCache } =
|
||||||
|
createSessionRuleScanCacheStore();
|
||||||
const { processFilePathForInjection } = createRuleInjectionProcessor({
|
const { processFilePathForInjection } = createRuleInjectionProcessor({
|
||||||
workspaceDirectory: ctx.directory,
|
workspaceDirectory: ctx.directory,
|
||||||
truncator,
|
truncator,
|
||||||
getSessionCache,
|
getSessionCache,
|
||||||
|
getSessionRuleScanCache,
|
||||||
ruleFinderOptions: options?.skipClaudeUserRules
|
ruleFinderOptions: options?.skipClaudeUserRules
|
||||||
? { skipClaudeUserRules: true }
|
? { skipClaudeUserRules: true }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function clearSessionState(sessionID: string): void {
|
||||||
|
clearSessionCache(sessionID);
|
||||||
|
clearSessionRuleScanCache(sessionID);
|
||||||
|
}
|
||||||
|
|
||||||
const toolExecuteAfter = async (
|
const toolExecuteAfter = async (
|
||||||
input: ToolExecuteInput,
|
input: ToolExecuteInput,
|
||||||
output: ToolExecuteOutput
|
output: ToolExecuteOutput
|
||||||
@@ -73,7 +81,7 @@ export function createRulesInjectorHook(
|
|||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
clearSessionCache(sessionInfo.id);
|
clearSessionState(sessionInfo.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +89,7 @@ export function createRulesInjectorHook(
|
|||||||
const sessionID = (props?.sessionID ??
|
const sessionID = (props?.sessionID ??
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
clearSessionCache(sessionID);
|
clearSessionState(sessionID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { parseRuleFrontmatter } from "./parser";
|
import { parseRuleFrontmatter } from "./parser";
|
||||||
import { saveInjectedRules } from "./storage";
|
import { saveInjectedRules } from "./storage";
|
||||||
import type { SessionInjectedRulesCache } from "./cache";
|
import type { SessionInjectedRulesCache } from "./cache";
|
||||||
|
import type { RuleScanCache } from "./rule-scan-cache";
|
||||||
import type { RuleMetadata } from "./types";
|
import type { RuleMetadata } from "./types";
|
||||||
|
|
||||||
type ToolExecuteOutput = {
|
type ToolExecuteOutput = {
|
||||||
@@ -56,6 +57,7 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
truncator: DynamicTruncator;
|
truncator: DynamicTruncator;
|
||||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||||
|
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
|
||||||
ruleFinderOptions?: FindRuleFilesOptions;
|
ruleFinderOptions?: FindRuleFilesOptions;
|
||||||
readFileSync?: typeof readFileSync;
|
readFileSync?: typeof readFileSync;
|
||||||
statSync?: typeof statSync;
|
statSync?: typeof statSync;
|
||||||
@@ -76,6 +78,7 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
truncator,
|
truncator,
|
||||||
getSessionCache,
|
getSessionCache,
|
||||||
|
getSessionRuleScanCache,
|
||||||
ruleFinderOptions,
|
ruleFinderOptions,
|
||||||
readFileSync: readRuleFileSync = readFileSync,
|
readFileSync: readRuleFileSync = readFileSync,
|
||||||
statSync: statRuleSync = statSync,
|
statSync: statRuleSync = statSync,
|
||||||
@@ -121,9 +124,16 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
|
|
||||||
const projectRoot = findProjectRoot(resolved);
|
const projectRoot = findProjectRoot(resolved);
|
||||||
const cache = getSessionCache(sessionID);
|
const cache = getSessionCache(sessionID);
|
||||||
|
const ruleScanCache = getSessionRuleScanCache?.(sessionID);
|
||||||
const home = getHomeDir();
|
const home = getHomeDir();
|
||||||
|
|
||||||
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved, ruleFinderOptions);
|
const ruleFileCandidates = findRuleFiles(
|
||||||
|
projectRoot,
|
||||||
|
home,
|
||||||
|
resolved,
|
||||||
|
ruleFinderOptions,
|
||||||
|
ruleScanCache,
|
||||||
|
);
|
||||||
const toInject: RuleToInject[] = [];
|
const toInject: RuleToInject[] = [];
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,108 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join, sep } from "node:path";
|
||||||
import {
|
import {
|
||||||
|
OPENCODE_USER_RULE_DIRS,
|
||||||
PROJECT_RULE_FILES,
|
PROJECT_RULE_FILES,
|
||||||
PROJECT_RULE_SUBDIRS,
|
PROJECT_RULE_SUBDIRS,
|
||||||
USER_RULE_DIR,
|
USER_RULE_DIR,
|
||||||
OPENCODE_USER_RULE_DIRS,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { RuleFileCandidate } from "./types";
|
import type { RuleScanCache } from "./rule-scan-cache";
|
||||||
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
|
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
|
||||||
|
import type { RuleFileCandidate } from "./types";
|
||||||
|
|
||||||
export interface FindRuleFilesOptions {
|
export interface FindRuleFilesOptions {
|
||||||
/**
|
|
||||||
* When true, skip loading rules from ~/.claude/rules/.
|
|
||||||
* Use when claude_code integration is disabled to prevent
|
|
||||||
* Claude Code-specific instructions from leaking into non-Claude agents.
|
|
||||||
*/
|
|
||||||
skipClaudeUserRules?: boolean;
|
skipClaudeUserRules?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getUserRuleDirs(homeDir: string, skipClaudeUserRules: boolean): string[] {
|
||||||
* Find all rule files for a given context.
|
const userRuleDirs = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
|
||||||
* Searches from currentFile upward to projectRoot for rule directories,
|
if (!skipClaudeUserRules) {
|
||||||
* then user-level directory (~/.claude/rules).
|
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
|
||||||
*
|
}
|
||||||
* IMPORTANT: This searches EVERY directory from file to project root.
|
return userRuleDirs;
|
||||||
* Not just the project root itself.
|
}
|
||||||
*
|
|
||||||
* @param projectRoot - Project root path (or null if outside any project)
|
function createCacheKey(
|
||||||
* @param homeDir - User home directory
|
projectRoot: string | null,
|
||||||
* @param currentFile - Current file being edited (for distance calculation)
|
startDir: string,
|
||||||
* @returns Array of rule file candidates sorted by distance
|
skipClaudeUserRules: boolean,
|
||||||
*/
|
): string {
|
||||||
|
return `${projectRoot ?? ""}|${startDir}|${skipClaudeUserRules ? "1" : "0"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCachedCandidate(
|
||||||
|
filePath: string,
|
||||||
|
projectRoot: string | null,
|
||||||
|
startDir: string,
|
||||||
|
userRuleDirs: string[],
|
||||||
|
): RuleFileCandidate | undefined {
|
||||||
|
const realPath = safeRealpathSync(filePath);
|
||||||
|
|
||||||
|
for (const userRuleDir of userRuleDirs) {
|
||||||
|
if (filePath.startsWith(`${userRuleDir}${sep}`)) {
|
||||||
|
return { path: filePath, realPath, isGlobal: true, distance: 9999 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectRoot) {
|
||||||
|
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||||
|
if (filePath === join(projectRoot, ruleFile)) {
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
realPath,
|
||||||
|
isGlobal: false,
|
||||||
|
distance: 0,
|
||||||
|
isSingleFile: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDir = startDir;
|
||||||
|
let distance = 0;
|
||||||
|
while (true) {
|
||||||
|
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||||
|
const ruleDir = join(currentDir, parent, subdir);
|
||||||
|
if (filePath.startsWith(`${ruleDir}${sep}`)) {
|
||||||
|
return { path: filePath, realPath, isGlobal: false, distance };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectRoot && currentDir === projectRoot) break;
|
||||||
|
const parentDir = dirname(currentDir);
|
||||||
|
if (parentDir === currentDir) break;
|
||||||
|
currentDir = parentDir;
|
||||||
|
distance += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function findRuleFiles(
|
export function findRuleFiles(
|
||||||
projectRoot: string | null,
|
projectRoot: string | null,
|
||||||
homeDir: string,
|
homeDir: string,
|
||||||
currentFile: string,
|
currentFile: string,
|
||||||
options?: FindRuleFilesOptions,
|
options?: FindRuleFilesOptions,
|
||||||
|
cache?: RuleScanCache,
|
||||||
): RuleFileCandidate[] {
|
): RuleFileCandidate[] {
|
||||||
|
const startDir = dirname(currentFile);
|
||||||
|
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
|
||||||
|
const userRuleDirs = getUserRuleDirs(homeDir, skipClaudeUserRules);
|
||||||
|
const cacheKey = createCacheKey(projectRoot, startDir, skipClaudeUserRules);
|
||||||
|
const cachedPaths = cache?.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedPaths) {
|
||||||
|
return cachedPaths
|
||||||
|
.map((filePath) => createCachedCandidate(filePath, projectRoot, startDir, userRuleDirs))
|
||||||
|
.filter((candidate): candidate is RuleFileCandidate => candidate !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
const candidates: RuleFileCandidate[] = [];
|
const candidates: RuleFileCandidate[] = [];
|
||||||
const seenRealPaths = new Set<string>();
|
const seenRealPaths = new Set<string>();
|
||||||
|
let currentDir = startDir;
|
||||||
// Search from current file's directory up to project root
|
|
||||||
let currentDir = dirname(currentFile);
|
|
||||||
let distance = 0;
|
let distance = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Search rule directories in current directory
|
|
||||||
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||||
const ruleDir = join(currentDir, parent, subdir);
|
const ruleDir = join(currentDir, parent, subdir);
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
@@ -55,60 +112,41 @@ export function findRuleFiles(
|
|||||||
const realPath = safeRealpathSync(filePath);
|
const realPath = safeRealpathSync(filePath);
|
||||||
if (seenRealPaths.has(realPath)) continue;
|
if (seenRealPaths.has(realPath)) continue;
|
||||||
seenRealPaths.add(realPath);
|
seenRealPaths.add(realPath);
|
||||||
|
candidates.push({ path: filePath, realPath, isGlobal: false, distance });
|
||||||
candidates.push({
|
|
||||||
path: filePath,
|
|
||||||
realPath,
|
|
||||||
isGlobal: false,
|
|
||||||
distance,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop at project root or filesystem root
|
|
||||||
if (projectRoot && currentDir === projectRoot) break;
|
if (projectRoot && currentDir === projectRoot) break;
|
||||||
const parentDir = dirname(currentDir);
|
const parentDir = dirname(currentDir);
|
||||||
if (parentDir === currentDir) break;
|
if (parentDir === currentDir) break;
|
||||||
currentDir = parentDir;
|
currentDir = parentDir;
|
||||||
distance++;
|
distance += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for single-file rules at project root (e.g., .github/copilot-instructions.md)
|
|
||||||
if (projectRoot) {
|
if (projectRoot) {
|
||||||
for (const ruleFile of PROJECT_RULE_FILES) {
|
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||||
const filePath = join(projectRoot, ruleFile);
|
const filePath = join(projectRoot, ruleFile);
|
||||||
if (existsSync(filePath)) {
|
if (!existsSync(filePath)) continue;
|
||||||
try {
|
|
||||||
const stat = statSync(filePath);
|
try {
|
||||||
if (stat.isFile()) {
|
const stat = statSync(filePath);
|
||||||
const realPath = safeRealpathSync(filePath);
|
if (!stat.isFile()) continue;
|
||||||
if (!seenRealPaths.has(realPath)) {
|
const realPath = safeRealpathSync(filePath);
|
||||||
seenRealPaths.add(realPath);
|
if (seenRealPaths.has(realPath)) continue;
|
||||||
candidates.push({
|
seenRealPaths.add(realPath);
|
||||||
path: filePath,
|
candidates.push({
|
||||||
realPath,
|
path: filePath,
|
||||||
isGlobal: false,
|
realPath,
|
||||||
distance: 0,
|
isGlobal: false,
|
||||||
isSingleFile: true,
|
distance: 0,
|
||||||
});
|
isSingleFile: true,
|
||||||
}
|
});
|
||||||
}
|
} catch {
|
||||||
} catch {
|
continue;
|
||||||
// Skip if file can't be read
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search user-level rule directories
|
|
||||||
// Always search OpenCode-native dirs (~/.sisyphus/rules, ~/.opencode/rules)
|
|
||||||
const userRuleDirs: string[] = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
|
|
||||||
|
|
||||||
// Only search ~/.claude/rules when claude_code integration is not disabled
|
|
||||||
if (!options?.skipClaudeUserRules) {
|
|
||||||
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const userRuleDir of userRuleDirs) {
|
for (const userRuleDir of userRuleDirs) {
|
||||||
const userFiles: string[] = [];
|
const userFiles: string[] = [];
|
||||||
findRuleFilesRecursive(userRuleDir, userFiles);
|
findRuleFilesRecursive(userRuleDir, userFiles);
|
||||||
@@ -117,23 +155,21 @@ export function findRuleFiles(
|
|||||||
const realPath = safeRealpathSync(filePath);
|
const realPath = safeRealpathSync(filePath);
|
||||||
if (seenRealPaths.has(realPath)) continue;
|
if (seenRealPaths.has(realPath)) continue;
|
||||||
seenRealPaths.add(realPath);
|
seenRealPaths.add(realPath);
|
||||||
|
candidates.push({ path: filePath, realPath, isGlobal: true, distance: 9999 });
|
||||||
candidates.push({
|
|
||||||
path: filePath,
|
|
||||||
realPath,
|
|
||||||
isGlobal: true,
|
|
||||||
distance: 9999, // Global rules always have max distance
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by distance (closest first, then global rules last)
|
candidates.sort((left, right) => {
|
||||||
candidates.sort((a, b) => {
|
if (left.isGlobal !== right.isGlobal) {
|
||||||
if (a.isGlobal !== b.isGlobal) {
|
return left.isGlobal ? 1 : -1;
|
||||||
return a.isGlobal ? 1 : -1;
|
|
||||||
}
|
}
|
||||||
return a.distance - b.distance;
|
return left.distance - right.distance;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cache?.set(
|
||||||
|
cacheKey,
|
||||||
|
candidates.map((candidate) => candidate.path),
|
||||||
|
);
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type RuleScanCache = {
|
||||||
|
get: (key: string) => string[] | undefined;
|
||||||
|
set: (key: string, value: string[]) => void;
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createRuleScanCache(): RuleScanCache {
|
||||||
|
const cache = new Map<string, string[]>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(key: string): string[] | undefined {
|
||||||
|
return cache.get(key);
|
||||||
|
},
|
||||||
|
set(key: string, value: string[]): void {
|
||||||
|
cache.set(key, value);
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
cache.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user