Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/directory-readme-injector/constants.ts
Original file line number Diff line number Diff line change
@@ -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";
126 changes: 126 additions & 0 deletions src/hooks/directory-readme-injector/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>();

function getSessionCache(sessionID: string): Set<string> {
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<string, unknown> | 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,
};
}
48 changes: 48 additions & 0 deletions src/hooks/directory-readme-injector/storage.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string>): 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);
}
}
5 changes: 5 additions & 0 deletions src/hooks/directory-readme-injector/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createCommentCheckerHooks,
createGrepOutputTruncatorHook,
createDirectoryAgentsInjectorHook,
createDirectoryReadmeInjectorHook,
createEmptyTaskResponseDetectorHook,
createThinkModeHook,
createClaudeCodeHooksHook,
Expand Down Expand Up @@ -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, {});
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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()) {
Expand Down