Something went wrong. Try again.
Something went wrong. Try again.
5.0 kB · 222 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223import { readFileSync, existsSync } from "node:fs";import { type Plugin, tool } from "@opencode-ai/plugin";
/** * Extension to language mapping for rule recommendations. * Only includes languages we have rule files for. */const EXTENSION_TO_LANGUAGE: Record<string, string> = { // TypeScript/JavaScript ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
// Go go: "go",
// Nix nix: "nix",
// Rust rs: "rust",
// Ruby rb: "ruby", erb: "ruby", rake: "ruby",
// Python py: "python", pyw: "python", pyi: "python",
// Java/C-family java: "java", c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", cs: "csharp",
// Shell sh: "shell", bash: "shell", zsh: "shell",
// Web html: "html", htm: "html", css: "css", scss: "css", sass: "css", less: "css",
// Data/Config json: "json", yaml: "yaml", yml: "yaml", xml: "xml", toml: "toml",
// Documentation md: "markdown", markdown: "markdown",
// Database sql: "sql", ddl: "sql",};
/** * Extract file extension and map to language name. */function getLanguageFromFilePath(filePath: string): string | null { const match = filePath.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/); if (!match) return null; const ext = match[1]!.toLowerCase(); return EXTENSION_TO_LANGUAGE[ext] ?? null;}
/** * Registry to track which rules have been read in each session. */const sessionRulesRead = new Map<string, Set<string>>();
/** * Cleanup old sessions to prevent memory leaks. * Should be called periodically or when sessions exceed a threshold. */function cleanupOldSessions(maxSessions = 1000): void { if (sessionRulesRead.size > maxSessions) { const entries = Array.from(sessionRulesRead.entries()); // Remove oldest half for (let i = 0; i < entries.length / 2; i++) { sessionRulesRead.delete(entries[i]![0]); } }}
/** * Finds the path to a rule file for the given language. * Returns the path if found, null otherwise. */function findRuleFile(directory: string, language: string): string | null { const paths = [`${directory}/.rules/${language}.md`];
const rulesDir = process.env.LANGRULES_DIR; if (rulesDir) { paths.push(`${rulesDir}/${language}.md`); }
for (const rulePath of paths) { if (existsSync(rulePath)) { return rulePath; } } return null;}
/** * Checks if rules should be prompted for a session/language combo. * Returns true if this is the first time, and marks it as read. */function shouldPromptForRules(sessionID: string, language: string): boolean { if (!sessionRulesRead.has(sessionID)) { sessionRulesRead.set(sessionID, new Set()); }
const rulesSet = sessionRulesRead.get(sessionID); if (!rulesSet || rulesSet.has(language)) { return false; }
rulesSet.add(language); return true;}
export const LangRulesPlugin: Plugin = async ({ client, directory }) => { return { tool: { langrules: tool({ description: "Read the critical rules needed for style and necessary context", args: { language: tool.schema .string() .describe( "Specific rule file to read (e.g., 'typescript', 'go', 'javascript')", ), }, async execute(args, toolCtx) { const sessionID = toolCtx?.sessionID; const languageFile = args.language;
if (sessionID) { if (!sessionRulesRead.has(sessionID)) { sessionRulesRead.set(sessionID, new Set()); cleanupOldSessions(); }
const rulesSet = sessionRulesRead.get(sessionID); if (rulesSet) { rulesSet.add(languageFile); } }
const rulePath = findRuleFile(directory, languageFile); if (!rulePath) { return `No rule file found for '${languageFile}'`; }
try { const content = readFileSync(rulePath, "utf-8"); if (content.trim()) return content.trim(); } catch (error) {}
return `No rule file found for '${languageFile}'`; }, }), },
"tool.execute.before": async (input, output) => { const { sessionID, tool: toolName } = input; const { args } = output;
if (!sessionID) return;
// Only prompt for edit and write tools to avoid context bloat during research if (toolName !== "edit" && toolName !== "write") return;
const filePath = (args?.filePath as string) || (args?.path as string); if (!filePath || typeof filePath !== "string") return;
const language = getLanguageFromFilePath(filePath); if (!language) return;
if (!findRuleFile(directory, language)) return;
if (shouldPromptForRules(sessionID, language)) { const ext = filePath.split(".").pop()?.toLowerCase() || ""; client.session.prompt({ path: { id: sessionID, }, body: { parts: [ { synthetic: true, type: "text", text: `**Hint:** You're about to modify a \`${ext}\` file (${language}). Consider reading the ${language} coding rules: \`langrules({ language: '${language}' })\``, }, ], }, }); } }, };};