From 9e00be91af9437ca8e06472b8569347ada139b65 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 11 Dec 2025 10:13:04 +0900 Subject: [PATCH] feat(hooks): add directory README.md injector (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements README.md injection similar to existing AGENTS.md injector. Automatically injects README.md contents when reading files, searching upward from file directory to project root. Closes #14 πŸ€– GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- README.ko.md | 1 + README.md | 1 + .../directory-readme-injector/constants.ts | 9 ++ src/hooks/directory-readme-injector/index.ts | 126 ++++++++++++++++++ .../directory-readme-injector/storage.ts | 48 +++++++ src/hooks/directory-readme-injector/types.ts | 5 + src/hooks/index.ts | 1 + src/index.ts | 4 + 8 files changed, 195 insertions(+) create mode 100644 src/hooks/directory-readme-injector/constants.ts create mode 100644 src/hooks/directory-readme-injector/index.ts create mode 100644 src/hooks/directory-readme-injector/storage.ts create mode 100644 src/hooks/directory-readme-injector/types.ts diff --git a/README.ko.md b/README.ko.md index 72e8987..8627937 100644 --- a/README.ko.md +++ b/README.ko.md @@ -166,6 +166,7 @@ OpenCode λŠ” μ•„μ£Ό ν™•μž₯κ°€λŠ₯ν•˜κ³  μ•„μ£Ό μ»€μŠ€ν„°λ§ˆμ΄μ €λΈ”ν•©λ‹ˆλ‹€. β”‚ └── Button.tsx # 이 νŒŒμΌμ„ 읽으면 μœ„ 3개 AGENTS.md λͺ¨λ‘ μ£Όμž… ``` `Button.tsx`λ₯Ό 읽으면 μˆœμ„œλŒ€λ‘œ μ£Όμž…λ©λ‹ˆλ‹€: `project/AGENTS.md` β†’ `src/AGENTS.md` β†’ `components/AGENTS.md`. 각 λ””λ ‰ν† λ¦¬μ˜ μ»¨ν…μŠ€νŠΈλŠ” μ„Έμ…˜λ‹Ή ν•œ 번만 μ£Όμž…λ©λ‹ˆλ‹€. Claude Code의 CLAUDE.md κΈ°λŠ₯μ—μ„œ μ˜κ°μ„ λ°›μ•˜μŠ΅λ‹ˆλ‹€. +- **Directory README.md Injector**: νŒŒμΌμ„ 읽을 λ•Œ `README.md` λ‚΄μš©μ„ μžλ™μœΌλ‘œ μ£Όμž…ν•©λ‹ˆλ‹€. AGENTS.md Injector와 λ™μΌν•˜κ²Œ λ™μž‘ν•˜λ©°, 파일 디렉토리뢀터 ν”„λ‘œμ νŠΈ λ£¨νŠΈκΉŒμ§€ νƒμƒ‰ν•©λ‹ˆλ‹€. LLM μ—μ΄μ „νŠΈμ—κ²Œ ν”„λ‘œμ νŠΈ λ¬Έμ„œ μ»¨ν…μŠ€νŠΈλ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. 각 λ””λ ‰ν† λ¦¬μ˜ READMEλŠ” μ„Έμ…˜λ‹Ή ν•œ 번만 μ£Όμž…λ©λ‹ˆλ‹€. - **Think Mode**: ν™•μž₯된 사고(Extended Thinking)κ°€ ν•„μš”ν•œ 상황을 μžλ™μœΌλ‘œ κ°μ§€ν•˜κ³  λͺ¨λ“œλ₯Ό μ „ν™˜ν•©λ‹ˆλ‹€. μ‚¬μš©μžκ°€ κΉŠμ€ 사고λ₯Ό μš”μ²­ν•˜λŠ” ν‘œν˜„(예: "think deeply", "ultrathink")을 κ°μ§€ν•˜λ©΄, μΆ”λ‘  λŠ₯λ ₯을 κ·ΉλŒ€ν™”ν•˜λ„λ‘ λͺ¨λΈ 섀정을 λ™μ μœΌλ‘œ μ‘°μ •ν•©λ‹ˆλ‹€. - **Anthropic Auto Compact**: Anthropic λͺ¨λΈ μ‚¬μš© μ‹œ μ»¨ν…μŠ€νŠΈ ν•œκ³„μ— λ„λ‹¬ν•˜λ©΄ λŒ€ν™” 기둝을 μžλ™μœΌλ‘œ μ••μΆ•ν•˜μ—¬ 효율적으둜 κ΄€λ¦¬ν•©λ‹ˆλ‹€. - **Empty Task Response Detector**: μ„œλΈŒ μ—μ΄μ „νŠΈκ°€ μˆ˜ν–‰ν•œ μž‘μ—…μ΄ λΉ„μ–΄μžˆκ±°λ‚˜ λ¬΄μ˜λ―Έν•œ 응닡을 λ°˜ν™˜ν•˜λŠ” 경우λ₯Ό κ°μ§€ν•˜μ—¬, 였λ₯˜ 없이 μš°μ•„ν•˜κ²Œ μ²˜λ¦¬ν•©λ‹ˆλ‹€. diff --git a/README.md b/README.md index adee45b..c1d2858 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI β”‚ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files ``` When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` β†’ `src/AGENTS.md` β†’ `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature. +- **Directory README.md Injector**: Automatically injects `README.md` contents when reading files. Works identically to the AGENTS.md Injector, searching upward from the file's directory to project root. Provides project documentation context to the LLM agent. Each directory's README is injected only once per session. - **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning. - **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models. - **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully. diff --git a/src/hooks/directory-readme-injector/constants.ts b/src/hooks/directory-readme-injector/constants.ts new file mode 100644 index 0000000..90c4b81 --- /dev/null +++ b/src/hooks/directory-readme-injector/constants.ts @@ -0,0 +1,9 @@ +import { join } from "node:path"; +import { xdgData } from "xdg-basedir"; + +export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage"); +export const README_INJECTOR_STORAGE = join( + OPENCODE_STORAGE, + "directory-readme", +); +export const README_FILENAME = "README.md"; diff --git a/src/hooks/directory-readme-injector/index.ts b/src/hooks/directory-readme-injector/index.ts new file mode 100644 index 0000000..92e0d42 --- /dev/null +++ b/src/hooks/directory-readme-injector/index.ts @@ -0,0 +1,126 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + loadInjectedPaths, + saveInjectedPaths, + clearInjectedPaths, +} from "./storage"; +import { README_FILENAME } from "./constants"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +export function createDirectoryReadmeInjectorHook(ctx: PluginInput) { + const sessionCaches = new Map>(); + + function getSessionCache(sessionID: string): Set { + if (!sessionCaches.has(sessionID)) { + sessionCaches.set(sessionID, loadInjectedPaths(sessionID)); + } + return sessionCaches.get(sessionID)!; + } + + function resolveFilePath(title: string): string | null { + if (!title) return null; + if (title.startsWith("/")) return title; + return resolve(ctx.directory, title); + } + + function findReadmeMdUp(startDir: string): string[] { + const found: string[] = []; + let current = startDir; + + while (true) { + const readmePath = join(current, README_FILENAME); + if (existsSync(readmePath)) { + found.push(readmePath); + } + + if (current === ctx.directory) break; + const parent = dirname(current); + if (parent === current) break; + if (!parent.startsWith(ctx.directory)) break; + current = parent; + } + + return found.reverse(); + } + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + if (input.tool.toLowerCase() !== "read") return; + + const filePath = resolveFilePath(output.title); + if (!filePath) return; + + const dir = dirname(filePath); + const cache = getSessionCache(input.sessionID); + const readmePaths = findReadmeMdUp(dir); + + const toInject: { path: string; content: string }[] = []; + + for (const readmePath of readmePaths) { + const readmeDir = dirname(readmePath); + if (cache.has(readmeDir)) continue; + + try { + const content = readFileSync(readmePath, "utf-8"); + toInject.push({ path: readmePath, content }); + cache.add(readmeDir); + } catch {} + } + + if (toInject.length === 0) return; + + for (const { path, content } of toInject) { + output.output += `\n\n[Project README: ${path}]\n${content}`; + } + + saveInjectedPaths(input.sessionID, cache); + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id) { + sessionCaches.delete(sessionInfo.id); + clearInjectedPaths(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); + clearInjectedPaths(sessionID); + } + } + }; + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/directory-readme-injector/storage.ts b/src/hooks/directory-readme-injector/storage.ts new file mode 100644 index 0000000..c4909f6 --- /dev/null +++ b/src/hooks/directory-readme-injector/storage.ts @@ -0,0 +1,48 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { README_INJECTOR_STORAGE } from "./constants"; +import type { InjectedPathsData } from "./types"; + +function getStoragePath(sessionID: string): string { + return join(README_INJECTOR_STORAGE, `${sessionID}.json`); +} + +export function loadInjectedPaths(sessionID: string): Set { + const filePath = getStoragePath(sessionID); + if (!existsSync(filePath)) return new Set(); + + try { + const content = readFileSync(filePath, "utf-8"); + const data: InjectedPathsData = JSON.parse(content); + return new Set(data.injectedPaths); + } catch { + return new Set(); + } +} + +export function saveInjectedPaths(sessionID: string, paths: Set): void { + if (!existsSync(README_INJECTOR_STORAGE)) { + mkdirSync(README_INJECTOR_STORAGE, { recursive: true }); + } + + const data: InjectedPathsData = { + sessionID, + injectedPaths: [...paths], + updatedAt: Date.now(), + }; + + writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2)); +} + +export function clearInjectedPaths(sessionID: string): void { + const filePath = getStoragePath(sessionID); + if (existsSync(filePath)) { + unlinkSync(filePath); + } +} diff --git a/src/hooks/directory-readme-injector/types.ts b/src/hooks/directory-readme-injector/types.ts new file mode 100644 index 0000000..7544e36 --- /dev/null +++ b/src/hooks/directory-readme-injector/types.ts @@ -0,0 +1,5 @@ +export interface InjectedPathsData { + sessionID: string; + injectedPaths: string[]; + updatedAt: number; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 889ed69..c05e16c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,6 +5,7 @@ export { createSessionRecoveryHook } from "./session-recovery"; export { createCommentCheckerHooks } from "./comment-checker"; export { createGrepOutputTruncatorHook } from "./grep-output-truncator"; export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector"; +export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector"; export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector"; export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact"; export { createThinkModeHook } from "./think-mode"; diff --git a/src/index.ts b/src/index.ts index d7204de..d7f18f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { createCommentCheckerHooks, createGrepOutputTruncatorHook, createDirectoryAgentsInjectorHook, + createDirectoryReadmeInjectorHook, createEmptyTaskResponseDetectorHook, createThinkModeHook, createClaudeCodeHooksHook, @@ -78,6 +79,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const commentChecker = createCommentCheckerHooks(); const grepOutputTruncator = createGrepOutputTruncatorHook(ctx); const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx); + const directoryReadmeInjector = createDirectoryReadmeInjectorHook(ctx); const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx); const thinkMode = createThinkModeHook(); const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {}); @@ -141,6 +143,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await todoContinuationEnforcer(input); await contextWindowMonitor.event(input); await directoryAgentsInjector.event(input); + await directoryReadmeInjector.event(input); await thinkMode.event(input); await anthropicAutoCompact.event(input); @@ -259,6 +262,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await contextWindowMonitor["tool.execute.after"](input, output); await commentChecker["tool.execute.after"](input, output); await directoryAgentsInjector["tool.execute.after"](input, output); + await directoryReadmeInjector["tool.execute.after"](input, output); await emptyTaskResponseDetector["tool.execute.after"](input, output); if (input.sessionID === getMainSessionID()) {