this repo has no description
1import { readFileSync, existsSync } from "node:fs";
2import { type Plugin, tool } from "@opencode-ai/plugin";
3
4/**
5 * Extension to language mapping for rule recommendations.
6 * Only includes languages we have rule files for.
7 */
8const EXTENSION_TO_LANGUAGE: Record<string, string> = {
9 // TypeScript/JavaScript
10 ts: "typescript",
11 tsx: "typescript",
12 js: "javascript",
13 jsx: "javascript",
14 mjs: "javascript",
15 cjs: "javascript",
16
17 // Go
18 go: "go",
19
20 // Nix
21 nix: "nix",
22
23 // Rust
24 rs: "rust",
25
26 // Ruby
27 rb: "ruby",
28 erb: "ruby",
29 rake: "ruby",
30
31 // Python
32 py: "python",
33 pyw: "python",
34 pyi: "python",
35
36 // Java/C-family
37 java: "java",
38 c: "c",
39 h: "c",
40 cpp: "cpp",
41 cc: "cpp",
42 cxx: "cpp",
43 hpp: "cpp",
44 cs: "csharp",
45
46 // Shell
47 sh: "shell",
48 bash: "shell",
49 zsh: "shell",
50
51 // Web
52 html: "html",
53 htm: "html",
54 css: "css",
55 scss: "css",
56 sass: "css",
57 less: "css",
58
59 // Data/Config
60 json: "json",
61 yaml: "yaml",
62 yml: "yaml",
63 xml: "xml",
64 toml: "toml",
65
66 // Documentation
67 md: "markdown",
68 markdown: "markdown",
69
70 // Database
71 sql: "sql",
72 ddl: "sql",
73};
74
75/**
76 * Extract file extension and map to language name.
77 */
78function getLanguageFromFilePath(filePath: string): string | null {
79 const match = filePath.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/);
80 if (!match) return null;
81 const ext = match[1]!.toLowerCase();
82 return EXTENSION_TO_LANGUAGE[ext] ?? null;
83}
84
85/**
86 * Registry to track which rules have been read in each session.
87 */
88const sessionRulesRead = new Map<string, Set<string>>();
89
90/**
91 * Cleanup old sessions to prevent memory leaks.
92 * Should be called periodically or when sessions exceed a threshold.
93 */
94function cleanupOldSessions(maxSessions = 1000): void {
95 if (sessionRulesRead.size > maxSessions) {
96 const entries = Array.from(sessionRulesRead.entries());
97 // Remove oldest half
98 for (let i = 0; i < entries.length / 2; i++) {
99 sessionRulesRead.delete(entries[i]![0]);
100 }
101 }
102}
103
104/**
105 * Finds the path to a rule file for the given language.
106 * Returns the path if found, null otherwise.
107 */
108function findRuleFile(directory: string, language: string): string | null {
109 const paths = [`${directory}/.rules/${language}.md`];
110
111 const rulesDir = process.env.LANGRULES_DIR;
112 if (rulesDir) {
113 paths.push(`${rulesDir}/${language}.md`);
114 }
115
116 for (const rulePath of paths) {
117 if (existsSync(rulePath)) {
118 return rulePath;
119 }
120 }
121 return null;
122}
123
124/**
125 * Checks if rules should be prompted for a session/language combo.
126 * Returns true if this is the first time, and marks it as read.
127 */
128function shouldPromptForRules(sessionID: string, language: string): boolean {
129 if (!sessionRulesRead.has(sessionID)) {
130 sessionRulesRead.set(sessionID, new Set());
131 }
132
133 const rulesSet = sessionRulesRead.get(sessionID);
134 if (!rulesSet || rulesSet.has(language)) {
135 return false;
136 }
137
138 rulesSet.add(language);
139 return true;
140}
141
142export const LangRulesPlugin: Plugin = async ({ client, directory }) => {
143 return {
144 tool: {
145 langrules: tool({
146 description:
147 "Read the critical rules needed for style and necessary context",
148 args: {
149 language: tool.schema
150 .string()
151 .describe(
152 "Specific rule file to read (e.g., 'typescript', 'go', 'javascript')",
153 ),
154 },
155 async execute(args, toolCtx) {
156 const sessionID = toolCtx?.sessionID;
157 const languageFile = args.language;
158
159 if (sessionID) {
160 if (!sessionRulesRead.has(sessionID)) {
161 sessionRulesRead.set(sessionID, new Set());
162 cleanupOldSessions();
163 }
164
165 const rulesSet = sessionRulesRead.get(sessionID);
166 if (rulesSet) {
167 rulesSet.add(languageFile);
168 }
169 }
170
171 const rulePath = findRuleFile(directory, languageFile);
172 if (!rulePath) {
173 return `No rule file found for '${languageFile}'`;
174 }
175
176 try {
177 const content = readFileSync(rulePath, "utf-8");
178 if (content.trim()) return content.trim();
179 } catch (error) {}
180
181 return `No rule file found for '${languageFile}'`;
182 },
183 }),
184 },
185
186 "tool.execute.before": async (input, output) => {
187 const { sessionID, tool: toolName } = input;
188 const { args } = output;
189
190 if (!sessionID) return;
191
192 // Only prompt for edit and write tools to avoid context bloat during research
193 if (toolName !== "edit" && toolName !== "write") return;
194
195 const filePath = (args?.filePath as string) || (args?.path as string);
196 if (!filePath || typeof filePath !== "string") return;
197
198 const language = getLanguageFromFilePath(filePath);
199 if (!language) return;
200
201 if (!findRuleFile(directory, language)) return;
202
203 if (shouldPromptForRules(sessionID, language)) {
204 const ext = filePath.split(".").pop()?.toLowerCase() || "";
205 client.session.prompt({
206 path: {
207 id: sessionID,
208 },
209 body: {
210 parts: [
211 {
212 synthetic: true,
213 type: "text",
214 text: `**Hint:** You're about to modify a \`${ext}\` file (${language}). Consider reading the ${language} coding rules: \`langrules({ language: '${language}' })\``,
215 },
216 ],
217 },
218 });
219 }
220 },
221 };
222};