diff --git a/src/hooks/rules-injector/hook.ts b/src/hooks/rules-injector/hook.ts index 51cd31146..0ba08af52 100644 --- a/src/hooks/rules-injector/hook.ts +++ b/src/hooks/rules-injector/hook.ts @@ -1,106 +1,115 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { resolveSessionEventID } from "../../shared/event-session-id"; -import { getRuleInjectionFilePath } from "./output-path"; -import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache"; +import { + createSessionCacheStore, + createSessionRuleScanCacheStore, +} from "./cache"; import { clearParsedRuleCache, createRuleInjectionProcessor } from "./injector"; +import { getRuleInjectionFilePath } from "./output-path"; import { clearProjectRootCache } from "./project-root-finder"; +import { createTranscriptHydrationStore } from "./transcript-hydration"; interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; + tool: string; + sessionID: string; + callID: string; } interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; + title: string; + output: string; + metadata: unknown; } interface ToolExecuteBeforeOutput { - args: unknown; + args: unknown; } interface EventInput { - event: { - type: string; - properties?: unknown; - }; + event: { + type: string; + properties?: unknown; + }; } const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"]; export function createRulesInjectorHook( - ctx: PluginInput, - modelCacheState?: { anthropicContext1MEnabled: boolean }, - options?: { skipClaudeUserRules?: boolean }, + ctx: PluginInput, + modelCacheState?: { anthropicContext1MEnabled: boolean }, + options?: { skipClaudeUserRules?: boolean }, ) { - const truncator = createDynamicTruncator(ctx, modelCacheState); - const { getSessionCache, clearSessionCache } = createSessionCacheStore(); - const { getSessionRuleScanCache, clearSessionRuleScanCache } = - createSessionRuleScanCacheStore(); - const { processFilePathForInjection } = createRuleInjectionProcessor({ - workspaceDirectory: ctx.directory, - truncator, - getSessionCache, - getSessionRuleScanCache, - ruleFinderOptions: options?.skipClaudeUserRules - ? { skipClaudeUserRules: true } - : undefined, - }); + const truncator = createDynamicTruncator(ctx, modelCacheState); + const { getSessionCache, clearSessionCache } = createSessionCacheStore(); + const { getSessionRuleScanCache, clearSessionRuleScanCache } = + createSessionRuleScanCacheStore(); + const transcriptHydration = createTranscriptHydrationStore({ + client: ctx.client, + }); + const { processFilePathForInjection } = createRuleInjectionProcessor({ + workspaceDirectory: ctx.directory, + truncator, + getSessionCache, + getSessionRuleScanCache, + transcriptHydration, + ruleFinderOptions: options?.skipClaudeUserRules + ? { skipClaudeUserRules: true } + : undefined, + }); - function clearSessionState(sessionID: string): void { - clearSessionCache(sessionID); - clearSessionRuleScanCache(sessionID); - clearParsedRuleCache(); - } + function clearSessionState(sessionID: string): void { + clearSessionCache(sessionID); + clearSessionRuleScanCache(sessionID); + transcriptHydration.clearSession(sessionID); + clearParsedRuleCache(); + } - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput - ) => { - const toolName = input.tool.toLowerCase(); + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + const toolName = input.tool.toLowerCase(); - if (TRACKED_TOOLS.includes(toolName)) { - const filePath = getRuleInjectionFilePath(output); - if (!filePath) return; - await processFilePathForInjection(filePath, input.sessionID, output); - return; - } - }; + if (TRACKED_TOOLS.includes(toolName)) { + const filePath = getRuleInjectionFilePath(output); + if (!filePath) return; + await processFilePathForInjection(filePath, input.sessionID, output); + return; + } + }; - const toolExecuteBefore = async ( - input: ToolExecuteInput, - output: ToolExecuteBeforeOutput - ): Promise => { - void input; - void output; - }; + const toolExecuteBefore = async ( + input: ToolExecuteInput, + output: ToolExecuteBeforeOutput, + ): Promise => { + void input; + void output; + }; - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; - if (event.type === "session.deleted") { - const sessionID = resolveSessionEventID(props); - if (sessionID) { - clearSessionState(sessionID); - } - clearProjectRootCache(); - } + if (event.type === "session.deleted") { + const sessionID = resolveSessionEventID(props); + if (sessionID) { + clearSessionState(sessionID); + } + clearProjectRootCache(); + } - if (event.type === "session.compacted") { - const sessionID = resolveSessionEventID(props); - if (sessionID) { - clearSessionState(sessionID); - } - clearProjectRootCache(); - } - }; + if (event.type === "session.compacted") { + const sessionID = resolveSessionEventID(props); + if (sessionID) { + clearSessionState(sessionID); + } + clearProjectRootCache(); + } + }; - return { - "tool.execute.before": toolExecuteBefore, - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; + return { + "tool.execute.before": toolExecuteBefore, + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; } diff --git a/src/hooks/rules-injector/injector.test.ts b/src/hooks/rules-injector/injector.test.ts index b34c94df3..796d0847b 100644 --- a/src/hooks/rules-injector/injector.test.ts +++ b/src/hooks/rules-injector/injector.test.ts @@ -398,4 +398,108 @@ describe("createRuleInjectionProcessor", () => { expect(trackedReadFileCount).toBe(2); expect(trackedShouldApplyRuleCount).toBe(2); }); + + it("#given transcript hydration reports prior rule banner #when same rule matches #then rule is skipped and cache absorbs the realPath", async () => { + // given + const hydratedRelativePath = + ".github/instructions/typescript.instructions.md"; + const sessionCaches = new Map< + string, + { contentHashes: Set; realPaths: Set } + >(); + const processor = createRuleInjectionProcessor({ + workspaceDirectory: projectRoot, + truncator: { + truncate: async (_sessionID: string, content: string) => ({ + result: content, + truncated: false, + }), + }, + getSessionCache: (sessionID: string) => { + if (!sessionCaches.has(sessionID)) { + sessionCaches.set(sessionID, { + contentHashes: new Set(), + realPaths: new Set(), + }); + } + const cache = sessionCaches.get(sessionID); + if (!cache) throw new Error("session cache should exist"); + return cache; + }, + homedir: () => homeRoot, + shouldApplyRule: () => ({ applies: true, reason: "matched" }), + isDuplicateByRealPath: (realPath: string, cache: ReadonlySet) => + cache.has(realPath), + createContentHash: (content: string) => `hash:${content}`, + isDuplicateByContentHash: (hash: string, cache: ReadonlySet) => + cache.has(hash), + transcriptHydration: { + hydrateSession: async () => new Set([hydratedRelativePath]), + }, + }); + + // when + const output = createOutput(); + await processor.processFilePathForInjection( + targetFile, + "session-1", + output, + ); + + // then + expect(output.output).toBe(""); + const cache = sessionCaches.get("session-1"); + expect(cache?.realPaths.has(ruleRealPath)).toBe(true); + }); + + it("#given transcript hydration reports unrelated rule #when injecting #then rule is still injected", async () => { + // given + const sessionCaches = new Map< + string, + { contentHashes: Set; realPaths: Set } + >(); + const processor = createRuleInjectionProcessor({ + workspaceDirectory: projectRoot, + truncator: { + truncate: async (_sessionID: string, content: string) => ({ + result: content, + truncated: false, + }), + }, + getSessionCache: (sessionID: string) => { + if (!sessionCaches.has(sessionID)) { + sessionCaches.set(sessionID, { + contentHashes: new Set(), + realPaths: new Set(), + }); + } + const cache = sessionCaches.get(sessionID); + if (!cache) throw new Error("session cache should exist"); + return cache; + }, + homedir: () => homeRoot, + shouldApplyRule: () => ({ applies: true, reason: "matched" }), + isDuplicateByRealPath: (realPath: string, cache: ReadonlySet) => + cache.has(realPath), + createContentHash: (content: string) => `hash:${content}`, + isDuplicateByContentHash: (hash: string, cache: ReadonlySet) => + cache.has(hash), + transcriptHydration: { + hydrateSession: async () => new Set(["some/other/rule.md"]), + }, + }); + + // when + const output = createOutput(); + await processor.processFilePathForInjection( + targetFile, + "session-1", + output, + ); + + // then + expect(output.output).toContain( + "[Rule: .github/instructions/typescript.instructions.md]", + ); + }); }); diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts index 365da972c..4f90e455c 100644 --- a/src/hooks/rules-injector/injector.ts +++ b/src/hooks/rules-injector/injector.ts @@ -1,8 +1,8 @@ import { readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { relative, resolve } from "node:path"; +import type { SessionInjectedRulesCache } from "./cache"; import { findProjectRoot, findRuleFiles } from "./finder"; -import type { FindRuleFilesOptions } from "./rule-file-finder"; import { createContentHash, isDuplicateByContentHash, @@ -10,9 +10,9 @@ import { shouldApplyRule, } from "./matcher"; import { parseRuleFrontmatter } from "./parser"; -import { saveInjectedRules } from "./storage"; -import type { SessionInjectedRulesCache } from "./cache"; +import type { FindRuleFilesOptions } from "./rule-file-finder"; import type { RuleScanCache } from "./rule-scan-cache"; +import { saveInjectedRules } from "./storage"; import type { RuleMetadata } from "./types"; type ToolExecuteOutput = { @@ -61,6 +61,7 @@ const MAX_PARSED_RULE_CACHE_ENTRIES = 256; const MAX_PARSED_RULE_CACHE_BODY_BYTES = 64 * 1024; const MAX_MATCH_DECISION_CACHE_ENTRIES = 4096; const parsedRuleCache = new Map(); +const EMPTY_TRANSCRIPT_SET: ReadonlySet = new Set(); export function clearParsedRuleCache(): void { parsedRuleCache.clear(); @@ -98,6 +99,10 @@ function resolveFilePath( return resolve(workspaceDirectory, path); } +export interface TranscriptHydrationHook { + hydrateSession(sessionID: string): Promise>; +} + export function createRuleInjectionProcessor(deps: { workspaceDirectory: string; truncator: DynamicTruncator; @@ -112,6 +117,7 @@ export function createRuleInjectionProcessor(deps: { createContentHash?: typeof createContentHash; isDuplicateByContentHash?: typeof isDuplicateByContentHash; saveInjectedRules?: typeof saveInjectedRules; + transcriptHydration?: TranscriptHydrationHook; }): { processFilePathForInjection: ( filePath: string, @@ -134,6 +140,7 @@ export function createRuleInjectionProcessor(deps: { isDuplicateByContentHash: isDuplicateByContentHashImpl = isDuplicateByContentHash, saveInjectedRules: saveInjectedRulesImpl = saveInjectedRules, + transcriptHydration, } = deps; const matchDecisionCache: MatchDecisionCache = new Map(); @@ -188,6 +195,10 @@ export function createRuleInjectionProcessor(deps: { const ruleScanCache = getSessionRuleScanCache?.(sessionID); const home = getHomeDir(); + const transcriptRelativePaths = transcriptHydration + ? await transcriptHydration.hydrateSession(sessionID) + : EMPTY_TRANSCRIPT_SET; + const ruleFileCandidates = findRuleFiles( projectRoot, home, @@ -259,6 +270,16 @@ export function createRuleInjectionProcessor(deps: { ? relative(projectRoot, candidate.path) : candidate.path; + if (transcriptRelativePaths.has(relativePath)) { + // Rule banner already present in the live transcript - record it in + // the persistent cache so subsequent calls also dedup correctly, + // then skip emitting another copy. + cache.realPaths.add(candidate.realPath); + cache.contentHashes.add(contentHash); + dirty = true; + continue; + } + toInject.push({ relativePath, matchReason, diff --git a/src/hooks/rules-injector/transcript-hydration.test.ts b/src/hooks/rules-injector/transcript-hydration.test.ts new file mode 100644 index 000000000..c51bce743 --- /dev/null +++ b/src/hooks/rules-injector/transcript-hydration.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { PluginInput } from "@opencode-ai/plugin"; +import { createTranscriptHydrationStore } from "./transcript-hydration"; + +type SessionClient = PluginInput["client"]["session"]; + +function makeClient( + messages: (sessionID: string) => Promise<{ data: unknown }>, +): PluginInput["client"] { + const session = { + messages: mock(async (args: { path: { id: string } }) => + messages(args.path.id), + ), + } as unknown as SessionClient; + return { session } as unknown as PluginInput["client"]; +} + +function ruleMarker(relativePath: string, body = "Rule body."): string { + return `\n\n[Rule: ${relativePath}]\n[Match: glob]\n${body}`; +} + +describe("createTranscriptHydrationStore", () => { + it("#given transcript with rule markers #when hydrateSession runs #then returns matched relativePaths", async () => { + // given + const transcript = { + data: [ + { + parts: [ + { + type: "tool", + output: + ruleMarker("AGENTS.md") + + ruleMarker(".omo/rules/typescript.md"), + }, + ], + }, + ], + }; + const store = createTranscriptHydrationStore({ + client: makeClient(async () => transcript), + }); + + // when + const relativePaths = await store.hydrateSession("session-1"); + + // then + expect([...relativePaths].sort()).toEqual([ + ".omo/rules/typescript.md", + "AGENTS.md", + ]); + }); + + it("#given empty transcript #when hydrateSession runs #then returns empty set", async () => { + // given + const store = createTranscriptHydrationStore({ + client: makeClient(async () => ({ data: [] })), + }); + + // when + const result = await store.hydrateSession("session-1"); + + // then + expect(result.size).toBe(0); + }); + + it("#given hydrateSession called twice #when second call runs #then session.messages fetched only once", async () => { + // given + let callCount = 0; + const store = createTranscriptHydrationStore({ + client: makeClient(async () => { + callCount += 1; + return { + data: [ + { parts: [{ type: "tool", output: ruleMarker("AGENTS.md") }] }, + ], + }; + }), + }); + + // when + await store.hydrateSession("session-1"); + await store.hydrateSession("session-1"); + + // then + expect(callCount).toBe(1); + }); + + it("#given concurrent hydrateSession calls #when both await #then only one fetch is in-flight", async () => { + // given + let resolveFetch: (() => void) | undefined; + let callCount = 0; + const store = createTranscriptHydrationStore({ + client: makeClient( + () => + new Promise((resolve) => { + callCount += 1; + resolveFetch = () => + resolve({ + data: [ + { + parts: [{ type: "tool", output: ruleMarker("AGENTS.md") }], + }, + ], + }); + }), + ), + }); + + // when + const a = store.hydrateSession("session-1"); + const b = store.hydrateSession("session-1"); + resolveFetch?.(); + const [resultA, resultB] = await Promise.all([a, b]); + + // then + expect(callCount).toBe(1); + expect([...resultA]).toEqual(["AGENTS.md"]); + expect([...resultB]).toEqual(["AGENTS.md"]); + }); + + it("#given fetch error #when hydrateSession runs #then returns empty set without throwing", async () => { + // given + const store = createTranscriptHydrationStore({ + client: makeClient(async () => { + throw new Error("network down"); + }), + }); + + // when + const result = await store.hydrateSession("session-1"); + + // then + expect(result.size).toBe(0); + }); + + it("#given hydrated session #when clearSession then re-hydrate #then transcript is rescanned", async () => { + // given + let callCount = 0; + const store = createTranscriptHydrationStore({ + client: makeClient(async () => { + callCount += 1; + return { + data: [ + { parts: [{ type: "tool", output: ruleMarker("AGENTS.md") }] }, + ], + }; + }), + }); + await store.hydrateSession("session-1"); + + // when + store.clearSession("session-1"); + await store.hydrateSession("session-1"); + + // then + expect(callCount).toBe(2); + }); + + it("#given marker line embedded inside larger text #when hydrate scans #then it still picks up the relativePath", async () => { + // given + const text = `some output\n\n[Rule: docs/AGENTS.md]\n[Match: alwaysApply]\nbody continues here\nmore lines`; + const store = createTranscriptHydrationStore({ + client: makeClient(async () => ({ data: [{ output: text }] })), + }); + + // when + const result = await store.hydrateSession("session-1"); + + // then + expect([...result]).toEqual(["docs/AGENTS.md"]); + }); +}); diff --git a/src/hooks/rules-injector/transcript-hydration.ts b/src/hooks/rules-injector/transcript-hydration.ts new file mode 100644 index 000000000..79623f64e --- /dev/null +++ b/src/hooks/rules-injector/transcript-hydration.ts @@ -0,0 +1,147 @@ +import type { PluginInput } from "@opencode-ai/plugin"; + +/** + * Pattern that matches the injector's own rule banner emitted into tool + * outputs. The capture group is the rule's path relative to project root. + * + * @see processFilePathForInjection in ./injector.ts where the marker is emitted. + */ +const RULE_MARKER_PATTERN = /\[Rule: ([^\]\n]+)\]\n\[Match: [^\]\n]+\]/g; + +/** + * Safety caps so transcript scanning cannot dominate a hook invocation when + * the session has accumulated a large number of messages. The newest + * messages are scanned first so that recent injections are detected even + * when the cap fires. + */ +const HYDRATION_MAX_MESSAGES = 200; +const HYDRATION_MAX_CHARS = 1_000_000; + +export interface TranscriptHydrationDeps { + readonly client: PluginInput["client"]; +} + +export interface TranscriptHydrationStore { + hydrateSession(sessionID: string): Promise>; + getHydratedRelativePaths(sessionID: string): ReadonlySet; + clearSession(sessionID: string): void; +} + +interface SessionHydrationState { + relativePaths: Set; + hydrated: boolean; + inflight?: Promise; +} + +/** + * Builds an in-memory store keyed by sessionID that lazily scans the session + * transcript for `[Rule: ]` markers and exposes the set of + * already-injected rule relative paths. The store is consulted by the + * injector before emitting a rule so a process that lost its persisted cache + * file but whose model context still contains prior `[Rule: ...]` markers + * does not re-inject duplicates. + */ +export function createTranscriptHydrationStore( + deps: TranscriptHydrationDeps, +): TranscriptHydrationStore { + const states = new Map(); + + function ensureState(sessionID: string): SessionHydrationState { + const existing = states.get(sessionID); + if (existing !== undefined) { + return existing; + } + const state: SessionHydrationState = { + relativePaths: new Set(), + hydrated: false, + }; + states.set(sessionID, state); + return state; + } + + async function hydrateSession( + sessionID: string, + ): Promise> { + const state = ensureState(sessionID); + if (state.hydrated) { + return state.relativePaths; + } + if (state.inflight === undefined) { + state.inflight = (async () => { + try { + const fetched = await fetchTranscriptRelativePaths( + deps.client, + sessionID, + ); + for (const relativePath of fetched) { + state.relativePaths.add(relativePath); + } + } catch { + // best-effort: a hydration failure must never block injection. + } finally { + state.hydrated = true; + state.inflight = undefined; + } + })(); + } + await state.inflight; + return state.relativePaths; + } + + function getHydratedRelativePaths(sessionID: string): ReadonlySet { + return states.get(sessionID)?.relativePaths ?? EMPTY_SET; + } + + function clearSession(sessionID: string): void { + states.delete(sessionID); + } + + return { hydrateSession, getHydratedRelativePaths, clearSession }; +} + +const EMPTY_SET: ReadonlySet = new Set(); + +async function fetchTranscriptRelativePaths( + client: PluginInput["client"], + sessionID: string, +): Promise> { + const relativePaths = new Set(); + const response = (await client.session.messages({ + path: { id: sessionID }, + })) as { data?: unknown }; + const data = Array.isArray(response.data) ? response.data : []; + const start = Math.max(0, data.length - HYDRATION_MAX_MESSAGES); + let scannedChars = 0; + for (let index = data.length - 1; index >= start; index -= 1) { + const text = collectMessageText(data[index]); + scannedChars += text.length; + for (const match of text.matchAll(RULE_MARKER_PATTERN)) { + const relativePath = match[1]; + if (relativePath !== undefined) { + relativePaths.add(relativePath); + } + } + if (scannedChars > HYDRATION_MAX_CHARS) { + break; + } + } + return relativePaths; +} + +function collectMessageText( + value: unknown, + accumulator: string[] = [], +): string { + if (typeof value === "string") { + accumulator.push(value); + } else if (Array.isArray(value)) { + for (const item of value) { + collectMessageText(item, accumulator); + } + } else if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) { + collectMessageText(item, accumulator); + } + } + return accumulator.join("\n"); +}