Skip to content

Commit 9ed23d4

Browse files
committed
feat(hooks): implement directory-agents-injector hook
1 parent 79b7911 commit 9ed23d4

File tree

4 files changed

+188
-0
lines changed

4 files changed

+188
-0
lines changed
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 AGENTS_INJECTOR_STORAGE = join(
6+
OPENCODE_STORAGE,
7+
"directory-agents",
8+
);
9+
export const AGENTS_FILENAME = "AGENTS.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 { AGENTS_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 createDirectoryAgentsInjectorHook(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 findAgentsMdUp(startDir: string): string[] {
47+
const found: string[] = [];
48+
let current = startDir;
49+
50+
while (true) {
51+
const agentsPath = join(current, AGENTS_FILENAME);
52+
if (existsSync(agentsPath)) {
53+
found.push(agentsPath);
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 agentsPaths = findAgentsMdUp(dir);
78+
79+
const toInject: { path: string; content: string }[] = [];
80+
81+
for (const agentsPath of agentsPaths) {
82+
const agentsDir = dirname(agentsPath);
83+
if (cache.has(agentsDir)) continue;
84+
85+
try {
86+
const content = readFileSync(agentsPath, "utf-8");
87+
toInject.push({ path: agentsPath, content });
88+
cache.add(agentsDir);
89+
} catch {}
90+
}
91+
92+
if (toInject.length === 0) return;
93+
94+
for (const { path, content } of toInject) {
95+
output.output += `\n\n[Directory Context: ${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 { AGENTS_INJECTOR_STORAGE } from "./constants";
10+
import type { InjectedPathsData } from "./types";
11+
12+
function getStoragePath(sessionID: string): string {
13+
return join(AGENTS_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(AGENTS_INJECTOR_STORAGE)) {
31+
mkdirSync(AGENTS_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+
}

0 commit comments

Comments
 (0)