Skip to content

Commit 9e00be9

Browse files
authored
feat(hooks): add directory README.md injector (#15)
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)
1 parent 40d4673 commit 9e00be9

File tree

8 files changed

+195
-0
lines changed

8 files changed

+195
-0
lines changed

README.ko.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
166166
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
167167
```
168168
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md``src/AGENTS.md``components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
169+
- **Directory README.md Injector**: 파일을 읽을 때 `README.md` 내용을 자동으로 주입합니다. AGENTS.md Injector와 동일하게 동작하며, 파일 디렉토리부터 프로젝트 루트까지 탐색합니다. LLM 에이전트에게 프로젝트 문서 컨텍스트를 제공합니다. 각 디렉토리의 README는 세션당 한 번만 주입됩니다.
169170
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
170171
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
171172
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
163163
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
164164
```
165165
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.
166+
- **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.
166167
- **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.
167168
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
168169
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { join } from "node:path";
2+
import { xdgData } from "xdg-basedir";
3+
4+
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
5+
export const README_INJECTOR_STORAGE = join(
6+
OPENCODE_STORAGE,
7+
"directory-readme",
8+
);
9+
export const README_FILENAME = "README.md";
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { PluginInput } from "@opencode-ai/plugin";
2+
import { existsSync, readFileSync } from "node:fs";
3+
import { dirname, join, resolve } from "node:path";
4+
import {
5+
loadInjectedPaths,
6+
saveInjectedPaths,
7+
clearInjectedPaths,
8+
} from "./storage";
9+
import { README_FILENAME } from "./constants";
10+
11+
interface ToolExecuteInput {
12+
tool: string;
13+
sessionID: string;
14+
callID: string;
15+
}
16+
17+
interface ToolExecuteOutput {
18+
title: string;
19+
output: string;
20+
metadata: unknown;
21+
}
22+
23+
interface EventInput {
24+
event: {
25+
type: string;
26+
properties?: unknown;
27+
};
28+
}
29+
30+
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
31+
const sessionCaches = new Map<string, Set<string>>();
32+
33+
function getSessionCache(sessionID: string): Set<string> {
34+
if (!sessionCaches.has(sessionID)) {
35+
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
36+
}
37+
return sessionCaches.get(sessionID)!;
38+
}
39+
40+
function resolveFilePath(title: string): string | null {
41+
if (!title) return null;
42+
if (title.startsWith("/")) return title;
43+
return resolve(ctx.directory, title);
44+
}
45+
46+
function findReadmeMdUp(startDir: string): string[] {
47+
const found: string[] = [];
48+
let current = startDir;
49+
50+
while (true) {
51+
const readmePath = join(current, README_FILENAME);
52+
if (existsSync(readmePath)) {
53+
found.push(readmePath);
54+
}
55+
56+
if (current === ctx.directory) break;
57+
const parent = dirname(current);
58+
if (parent === current) break;
59+
if (!parent.startsWith(ctx.directory)) break;
60+
current = parent;
61+
}
62+
63+
return found.reverse();
64+
}
65+
66+
const toolExecuteAfter = async (
67+
input: ToolExecuteInput,
68+
output: ToolExecuteOutput,
69+
) => {
70+
if (input.tool.toLowerCase() !== "read") return;
71+
72+
const filePath = resolveFilePath(output.title);
73+
if (!filePath) return;
74+
75+
const dir = dirname(filePath);
76+
const cache = getSessionCache(input.sessionID);
77+
const readmePaths = findReadmeMdUp(dir);
78+
79+
const toInject: { path: string; content: string }[] = [];
80+
81+
for (const readmePath of readmePaths) {
82+
const readmeDir = dirname(readmePath);
83+
if (cache.has(readmeDir)) continue;
84+
85+
try {
86+
const content = readFileSync(readmePath, "utf-8");
87+
toInject.push({ path: readmePath, content });
88+
cache.add(readmeDir);
89+
} catch {}
90+
}
91+
92+
if (toInject.length === 0) return;
93+
94+
for (const { path, content } of toInject) {
95+
output.output += `\n\n[Project README: ${path}]\n${content}`;
96+
}
97+
98+
saveInjectedPaths(input.sessionID, cache);
99+
};
100+
101+
const eventHandler = async ({ event }: EventInput) => {
102+
const props = event.properties as Record<string, unknown> | undefined;
103+
104+
if (event.type === "session.deleted") {
105+
const sessionInfo = props?.info as { id?: string } | undefined;
106+
if (sessionInfo?.id) {
107+
sessionCaches.delete(sessionInfo.id);
108+
clearInjectedPaths(sessionInfo.id);
109+
}
110+
}
111+
112+
if (event.type === "session.compacted") {
113+
const sessionID = (props?.sessionID ??
114+
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
115+
if (sessionID) {
116+
sessionCaches.delete(sessionID);
117+
clearInjectedPaths(sessionID);
118+
}
119+
}
120+
};
121+
122+
return {
123+
"tool.execute.after": toolExecuteAfter,
124+
event: eventHandler,
125+
};
126+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
readFileSync,
5+
writeFileSync,
6+
unlinkSync,
7+
} from "node:fs";
8+
import { join } from "node:path";
9+
import { README_INJECTOR_STORAGE } from "./constants";
10+
import type { InjectedPathsData } from "./types";
11+
12+
function getStoragePath(sessionID: string): string {
13+
return join(README_INJECTOR_STORAGE, `${sessionID}.json`);
14+
}
15+
16+
export function loadInjectedPaths(sessionID: string): Set<string> {
17+
const filePath = getStoragePath(sessionID);
18+
if (!existsSync(filePath)) return new Set();
19+
20+
try {
21+
const content = readFileSync(filePath, "utf-8");
22+
const data: InjectedPathsData = JSON.parse(content);
23+
return new Set(data.injectedPaths);
24+
} catch {
25+
return new Set();
26+
}
27+
}
28+
29+
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
30+
if (!existsSync(README_INJECTOR_STORAGE)) {
31+
mkdirSync(README_INJECTOR_STORAGE, { recursive: true });
32+
}
33+
34+
const data: InjectedPathsData = {
35+
sessionID,
36+
injectedPaths: [...paths],
37+
updatedAt: Date.now(),
38+
};
39+
40+
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
41+
}
42+
43+
export function clearInjectedPaths(sessionID: string): void {
44+
const filePath = getStoragePath(sessionID);
45+
if (existsSync(filePath)) {
46+
unlinkSync(filePath);
47+
}
48+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface InjectedPathsData {
2+
sessionID: string;
3+
injectedPaths: string[];
4+
updatedAt: number;
5+
}

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { createSessionRecoveryHook } from "./session-recovery";
55
export { createCommentCheckerHooks } from "./comment-checker";
66
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
77
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
8+
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
89
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
910
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
1011
export { createThinkModeHook } from "./think-mode";

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createCommentCheckerHooks,
88
createGrepOutputTruncatorHook,
99
createDirectoryAgentsInjectorHook,
10+
createDirectoryReadmeInjectorHook,
1011
createEmptyTaskResponseDetectorHook,
1112
createThinkModeHook,
1213
createClaudeCodeHooksHook,
@@ -78,6 +79,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
7879
const commentChecker = createCommentCheckerHooks();
7980
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
8081
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
82+
const directoryReadmeInjector = createDirectoryReadmeInjectorHook(ctx);
8183
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
8284
const thinkMode = createThinkModeHook();
8385
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {});
@@ -141,6 +143,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
141143
await todoContinuationEnforcer(input);
142144
await contextWindowMonitor.event(input);
143145
await directoryAgentsInjector.event(input);
146+
await directoryReadmeInjector.event(input);
144147
await thinkMode.event(input);
145148
await anthropicAutoCompact.event(input);
146149

@@ -259,6 +262,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
259262
await contextWindowMonitor["tool.execute.after"](input, output);
260263
await commentChecker["tool.execute.after"](input, output);
261264
await directoryAgentsInjector["tool.execute.after"](input, output);
265+
await directoryReadmeInjector["tool.execute.after"](input, output);
262266
await emptyTaskResponseDetector["tool.execute.after"](input, output);
263267

264268
if (input.sessionID === getMainSessionID()) {

0 commit comments

Comments
 (0)