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)
This commit is contained in:
YeonGyu-Kim
2025-12-11 10:13:04 +09:00
committed by GitHub
parent 40d4673201
commit 9e00be91af
8 changed files with 195 additions and 0 deletions

View File

@@ -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";

View File

@@ -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,
};
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}