diff --git a/src/hooks/directory-agents-injector/constants.ts b/src/hooks/directory-agents-injector/constants.ts new file mode 100644 index 0000000..5208e85 --- /dev/null +++ b/src/hooks/directory-agents-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 AGENTS_INJECTOR_STORAGE = join( + OPENCODE_STORAGE, + "directory-agents", +); +export const AGENTS_FILENAME = "AGENTS.md"; diff --git a/src/hooks/directory-agents-injector/index.ts b/src/hooks/directory-agents-injector/index.ts new file mode 100644 index 0000000..49d8324 --- /dev/null +++ b/src/hooks/directory-agents-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 { AGENTS_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 createDirectoryAgentsInjectorHook(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 findAgentsMdUp(startDir: string): string[] { + const found: string[] = []; + let current = startDir; + + while (true) { + const agentsPath = join(current, AGENTS_FILENAME); + if (existsSync(agentsPath)) { + found.push(agentsPath); + } + + 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 agentsPaths = findAgentsMdUp(dir); + + const toInject: { path: string; content: string }[] = []; + + for (const agentsPath of agentsPaths) { + const agentsDir = dirname(agentsPath); + if (cache.has(agentsDir)) continue; + + try { + const content = readFileSync(agentsPath, "utf-8"); + toInject.push({ path: agentsPath, content }); + cache.add(agentsDir); + } catch {} + } + + if (toInject.length === 0) return; + + for (const { path, content } of toInject) { + output.output += `\n\n[Directory Context: ${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-agents-injector/storage.ts b/src/hooks/directory-agents-injector/storage.ts new file mode 100644 index 0000000..38f3730 --- /dev/null +++ b/src/hooks/directory-agents-injector/storage.ts @@ -0,0 +1,48 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { AGENTS_INJECTOR_STORAGE } from "./constants"; +import type { InjectedPathsData } from "./types"; + +function getStoragePath(sessionID: string): string { + return join(AGENTS_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(AGENTS_INJECTOR_STORAGE)) { + mkdirSync(AGENTS_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-agents-injector/types.ts b/src/hooks/directory-agents-injector/types.ts new file mode 100644 index 0000000..7544e36 --- /dev/null +++ b/src/hooks/directory-agents-injector/types.ts @@ -0,0 +1,5 @@ +export interface InjectedPathsData { + sessionID: string; + injectedPaths: string[]; + updatedAt: number; +}