this repo has no description
at main 222 lines 5.0 kB view raw
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};