feat(hooks): add agent-usage-reminder hook for background agent recommendations

Implements hook that tracks whether explore/librarian agents have been used in a session.
When target tools (Grep, Glob, WebFetch, context7, websearch_exa, grep_app) are called
without prior agent usage, appends reminder message recommending parallel background_task calls.

State persists across tool calls and resets on session compaction, allowing fresh reminders
after context compaction - similar to directory-readme-injector pattern.

Files:
- src/hooks/agent-usage-reminder/: New hook implementation
  - types.ts: AgentUsageState interface
  - constants.ts: TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE
  - storage.ts: File-based state persistence with compaction handling
  - index.ts: Hook implementation with tool.execute.after and event handlers
- src/config/schema.ts: Add 'agent-usage-reminder' to HookNameSchema
- src/hooks/index.ts: Export createAgentUsageReminderHook
- src/index.ts: Instantiate and register hook handlers

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-13 21:42:05 +09:00
parent 64b53c0e1c
commit 2c6dfeadce
7 changed files with 222 additions and 0 deletions

View File

@@ -41,6 +41,7 @@ export const HookNameSchema = z.enum([
"background-notification", "background-notification",
"auto-update-checker", "auto-update-checker",
"ultrawork-mode", "ultrawork-mode",
"agent-usage-reminder",
]) ])
export const AgentOverrideConfigSchema = z.object({ export const AgentOverrideConfigSchema = z.object({

View File

@@ -0,0 +1,52 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const AGENT_USAGE_REMINDER_STORAGE = join(
OPENCODE_STORAGE,
"agent-usage-reminder",
);
export const TARGET_TOOLS = [
"Grep",
"safe_grep",
"Glob",
"safe_glob",
"WebFetch",
"context7_resolve-library-id",
"context7_get-library-docs",
"websearch_exa_web_search_exa",
"grep_app_searchGitHub",
] as const;
export const AGENT_TOOLS = [
"Task",
"call_omo_agent",
"background_task",
] as const;
export const REMINDER_MESSAGE = `
[Agent Usage Reminder]
You called a search/fetch tool directly without leveraging specialized agents.
RECOMMENDED: Use background_task with explore/librarian agents for better results:
\`\`\`
// Parallel exploration - fire multiple agents simultaneously
background_task(agent="explore", prompt="Find all files matching pattern X")
background_task(agent="explore", prompt="Search for implementation of Y")
background_task(agent="librarian", prompt="Lookup documentation for Z")
// Then continue your work while they run in background
// System will notify you when each completes
\`\`\`
WHY:
- Agents can perform deeper, more thorough searches
- Background tasks run in parallel, saving time
- Specialized agents have domain expertise
- Reduces context window usage in main session
ALWAYS prefer: Multiple parallel background_task calls > Direct tool calls
`;

View File

@@ -0,0 +1,114 @@
import type { PluginInput } from "@opencode-ai/plugin";
import {
loadAgentUsageState,
saveAgentUsageState,
clearAgentUsageState,
} from "./storage";
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
parentSessionID?: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
export function createAgentUsageReminderHook(_ctx: PluginInput) {
const sessionStates = new Map<string, AgentUsageState>();
function getOrCreateState(sessionID: string): AgentUsageState {
if (!sessionStates.has(sessionID)) {
const persisted = loadAgentUsageState(sessionID);
const state: AgentUsageState = persisted ?? {
sessionID,
agentUsed: false,
reminderCount: 0,
updatedAt: Date.now(),
};
sessionStates.set(sessionID, state);
}
return sessionStates.get(sessionID)!;
}
function markAgentUsed(sessionID: string): void {
const state = getOrCreateState(sessionID);
state.agentUsed = true;
state.updatedAt = Date.now();
saveAgentUsageState(state);
}
function resetState(sessionID: string): void {
sessionStates.delete(sessionID);
clearAgentUsageState(sessionID);
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
const { tool, sessionID, parentSessionID } = input;
// Only run in root sessions (no parent = main session)
if (parentSessionID) {
return;
}
if ((AGENT_TOOLS as readonly string[]).includes(tool)) {
markAgentUsed(sessionID);
return;
}
if (!(TARGET_TOOLS as readonly string[]).includes(tool)) {
return;
}
const state = getOrCreateState(sessionID);
if (state.agentUsed) {
return;
}
output.output += REMINDER_MESSAGE;
state.reminderCount++;
state.updatedAt = Date.now();
saveAgentUsageState(state);
};
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) {
resetState(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
resetState(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}

View File

@@ -0,0 +1,42 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { AGENT_USAGE_REMINDER_STORAGE } from "./constants";
import type { AgentUsageState } from "./types";
function getStoragePath(sessionID: string): string {
return join(AGENT_USAGE_REMINDER_STORAGE, `${sessionID}.json`);
}
export function loadAgentUsageState(sessionID: string): AgentUsageState | null {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return null;
try {
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content) as AgentUsageState;
} catch {
return null;
}
}
export function saveAgentUsageState(state: AgentUsageState): void {
if (!existsSync(AGENT_USAGE_REMINDER_STORAGE)) {
mkdirSync(AGENT_USAGE_REMINDER_STORAGE, { recursive: true });
}
const filePath = getStoragePath(state.sessionID);
writeFileSync(filePath, JSON.stringify(state, null, 2));
}
export function clearAgentUsageState(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}

View File

@@ -0,0 +1,6 @@
export interface AgentUsageState {
sessionID: string;
agentUsed: boolean;
reminderCount: number;
updatedAt: number;
}

View File

@@ -14,3 +14,4 @@ export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification" export { createBackgroundNotificationHook } from "./background-notification"
export { createAutoUpdateCheckerHook } from "./auto-update-checker"; export { createAutoUpdateCheckerHook } from "./auto-update-checker";
export { createUltraworkModeHook } from "./ultrawork-mode"; export { createUltraworkModeHook } from "./ultrawork-mode";
export { createAgentUsageReminderHook } from "./agent-usage-reminder";

View File

@@ -17,6 +17,7 @@ import {
createBackgroundNotificationHook, createBackgroundNotificationHook,
createAutoUpdateCheckerHook, createAutoUpdateCheckerHook,
createUltraworkModeHook, createUltraworkModeHook,
createAgentUsageReminderHook,
} from "./hooks"; } from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import { import {
@@ -207,6 +208,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const ultraworkMode = isHookEnabled("ultrawork-mode") const ultraworkMode = isHookEnabled("ultrawork-mode")
? createUltraworkModeHook() ? createUltraworkModeHook()
: null; : null;
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
? createAgentUsageReminderHook(ctx)
: null;
updateTerminalTitle({ sessionId: "main" }); updateTerminalTitle({ sessionId: "main" });
@@ -320,6 +324,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await thinkMode?.event(input); await thinkMode?.event(input);
await anthropicAutoCompact?.event(input); await anthropicAutoCompact?.event(input);
await ultraworkMode?.event(input); await ultraworkMode?.event(input);
await agentUsageReminder?.event(input);
const { event } = input; const { event } = input;
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
@@ -439,6 +444,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await directoryReadmeInjector?.["tool.execute.after"](input, output); await directoryReadmeInjector?.["tool.execute.after"](input, output);
await rulesInjector?.["tool.execute.after"](input, output); await rulesInjector?.["tool.execute.after"](input, output);
await emptyTaskResponseDetector?.["tool.execute.after"](input, output); await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
await agentUsageReminder?.["tool.execute.after"](input, output);
if (input.sessionID === getMainSessionID()) { if (input.sessionID === getMainSessionID()) {
updateTerminalTitle({ updateTerminalTitle({