refactor: wave 1 - extract leaf modules, rename catch-all files, split index.ts hooks
- Split 25+ index.ts files into hook.ts + extracted modules - Rename all catch-all utils.ts/helpers.ts to domain-specific names - Split src/tools/lsp/ into ~15 focused modules - Split src/tools/delegate-task/ into ~18 focused modules - Separate shared types from implementation - 155 files changed, 60+ new files created - All typecheck clean, 61 tests pass
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { clearInjectedRules, loadInjectedRules } from "./storage";
|
||||
|
||||
export type SessionInjectedRulesCache = {
|
||||
contentHashes: Set<string>;
|
||||
realPaths: Set<string>;
|
||||
};
|
||||
|
||||
export function createSessionCacheStore(): {
|
||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||
clearSessionCache: (sessionID: string) => void;
|
||||
} {
|
||||
const sessionCaches = new Map<string, SessionInjectedRulesCache>();
|
||||
|
||||
function getSessionCache(sessionID: string): SessionInjectedRulesCache {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedRules(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function clearSessionCache(sessionID: string): void {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedRules(sessionID);
|
||||
}
|
||||
|
||||
return { getSessionCache, clearSessionCache };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { getRuleInjectionFilePath } from "./output-path";
|
||||
import { createSessionCacheStore } from "./cache";
|
||||
import { createRuleInjectionProcessor } from "./injector";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"];
|
||||
|
||||
export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
|
||||
const { processFilePathForInjection } = createRuleInjectionProcessor({
|
||||
workspaceDirectory: ctx.directory,
|
||||
truncator,
|
||||
getSessionCache,
|
||||
});
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput
|
||||
) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (TRACKED_TOOLS.includes(toolName)) {
|
||||
const filePath = getRuleInjectionFilePath(output);
|
||||
if (!filePath) return;
|
||||
await processFilePathForInjection(filePath, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionCache(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
clearSessionCache(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@@ -1,190 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
import {
|
||||
createContentHash,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
shouldApplyRule,
|
||||
} from "./matcher";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
import {
|
||||
clearInjectedRules,
|
||||
loadInjectedRules,
|
||||
saveInjectedRules,
|
||||
} from "./storage";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { getRuleInjectionFilePath } from "./output-path";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface RuleToInject {
|
||||
relativePath: string;
|
||||
matchReason: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"];
|
||||
|
||||
export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<
|
||||
string,
|
||||
{ contentHashes: Set<string>; realPaths: Set<string> }
|
||||
>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
function getSessionCache(sessionID: string): {
|
||||
contentHashes: Set<string>;
|
||||
realPaths: Set<string>;
|
||||
} {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedRules(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(path: string): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(ctx.directory, path);
|
||||
}
|
||||
|
||||
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const projectRoot = findProjectRoot(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const home = homedir();
|
||||
|
||||
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved);
|
||||
const toInject: RuleToInject[] = [];
|
||||
|
||||
for (const candidate of ruleFileCandidates) {
|
||||
if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue;
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(candidate.path, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
matchReason = matchResult.reason ?? "matched";
|
||||
}
|
||||
|
||||
const contentHash = createContentHash(body);
|
||||
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
|
||||
|
||||
const relativePath = projectRoot
|
||||
? relative(projectRoot, candidate.path)
|
||||
: candidate.path;
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
cache.realPaths.add(candidate.realPath);
|
||||
cache.contentHashes.add(contentHash);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const rule of toInject) {
|
||||
const { result, truncated } = await truncator.truncate(sessionID, rule.content);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
|
||||
}
|
||||
|
||||
saveInjectedRules(sessionID, cache);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput
|
||||
) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (TRACKED_TOOLS.includes(toolName)) {
|
||||
const filePath = getRuleInjectionFilePath(output);
|
||||
if (!filePath) return;
|
||||
await processFilePathForInjection(filePath, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedRules(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedRules(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
export { createRulesInjectorHook } from "./hook";
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
import {
|
||||
createContentHash,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
shouldApplyRule,
|
||||
} from "./matcher";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
import { saveInjectedRules } from "./storage";
|
||||
import type { SessionInjectedRulesCache } from "./cache";
|
||||
|
||||
type ToolExecuteOutput = {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
};
|
||||
|
||||
type RuleToInject = {
|
||||
relativePath: string;
|
||||
matchReason: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
};
|
||||
|
||||
type DynamicTruncator = {
|
||||
truncate: (
|
||||
sessionID: string,
|
||||
content: string
|
||||
) => Promise<{ result: string; truncated: boolean }>;
|
||||
};
|
||||
|
||||
function resolveFilePath(
|
||||
workspaceDirectory: string,
|
||||
path: string
|
||||
): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(workspaceDirectory, path);
|
||||
}
|
||||
|
||||
export function createRuleInjectionProcessor(deps: {
|
||||
workspaceDirectory: string;
|
||||
truncator: DynamicTruncator;
|
||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||
}): {
|
||||
processFilePathForInjection: (
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
) => Promise<void>;
|
||||
} {
|
||||
const { workspaceDirectory, truncator, getSessionCache } = deps;
|
||||
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(workspaceDirectory, filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const projectRoot = findProjectRoot(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const home = homedir();
|
||||
|
||||
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved);
|
||||
const toInject: RuleToInject[] = [];
|
||||
|
||||
for (const candidate of ruleFileCandidates) {
|
||||
if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue;
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(candidate.path, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
matchReason = matchResult.reason ?? "matched";
|
||||
}
|
||||
|
||||
const contentHash = createContentHash(body);
|
||||
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
|
||||
|
||||
const relativePath = projectRoot
|
||||
? relative(projectRoot, candidate.path)
|
||||
: candidate.path;
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
cache.realPaths.add(candidate.realPath);
|
||||
cache.contentHashes.add(contentHash);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const rule of toInject) {
|
||||
const { result, truncated } = await truncator.truncate(
|
||||
sessionID,
|
||||
rule.content
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
|
||||
}
|
||||
|
||||
saveInjectedRules(sessionID, cache);
|
||||
}
|
||||
|
||||
return { processFilePathForInjection };
|
||||
}
|
||||
Reference in New Issue
Block a user