Files
oh-my-opencode/src/hooks/rules-injector/transcript-hydration.ts
T
YeonGyu-Kim fbe423a2d4 feat(rules-injector): hydrate dedup cache from session transcript
Ports the codex-rules transcript-aware dedup strategy: when a session cache is fresh (process restart or compaction-cleared cache), the injector now scans prior tool outputs for the `[Rule: <relativePath>]` banner and pre-populates the cache so duplicate rule injections are suppressed even when the persistent JSON has been lost. Hydration runs at most once per session per process, fails open on transport errors, and is short-circuited when the same banner reappears.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 13:58:13 +09:00

148 lines
4.2 KiB
TypeScript

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<ReadonlySet<string>>;
getHydratedRelativePaths(sessionID: string): ReadonlySet<string>;
clearSession(sessionID: string): void;
}
interface SessionHydrationState {
relativePaths: Set<string>;
hydrated: boolean;
inflight?: Promise<void>;
}
/**
* Builds an in-memory store keyed by sessionID that lazily scans the session
* transcript for `[Rule: <relativePath>]` 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<string, SessionHydrationState>();
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<ReadonlySet<string>> {
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<string> {
return states.get(sessionID)?.relativePaths ?? EMPTY_SET;
}
function clearSession(sessionID: string): void {
states.delete(sessionID);
}
return { hydrateSession, getHydratedRelativePaths, clearSession };
}
const EMPTY_SET: ReadonlySet<string> = new Set();
async function fetchTranscriptRelativePaths(
client: PluginInput["client"],
sessionID: string,
): Promise<Set<string>> {
const relativePaths = new Set<string>();
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");
}