diff --git a/src/config/schema.ts b/src/config/schema.ts index e4c55c6ff3..47bcffda34 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -11,6 +11,7 @@ export * from "./schema/dynamic-context-pruning" export * from "./schema/experimental" export * from "./schema/git-master" export * from "./schema/hooks" +export * from "./schema/keyword-detector" export * from "./schema/notification" export * from "./schema/oh-my-opencode-config" export * from "./schema/ralph-loop" diff --git a/src/config/schema/keyword-detector.ts b/src/config/schema/keyword-detector.ts new file mode 100644 index 0000000000..1c62a887c6 --- /dev/null +++ b/src/config/schema/keyword-detector.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const KeywordDetectorConfigSchema = z.object({ + /** Additional trigger words/phrases that activate ultrawork mode (alongside built-in "ultrawork" and "ulw") */ + extra_ultrawork_aliases: z.array(z.string()).optional(), +}) + +export type KeywordDetectorConfig = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index be0ebd9149..d5c3073cb5 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -8,6 +8,7 @@ import { BrowserAutomationConfigSchema } from "./browser-automation" import { CategoriesConfigSchema } from "./categories" import { ClaudeCodeConfigSchema } from "./claude-code" import { CommentCheckerConfigSchema } from "./comment-checker" +import { KeywordDetectorConfigSchema } from "./keyword-detector" import { BuiltinCommandNameSchema } from "./commands" import { ExperimentalConfigSchema } from "./experimental" import { GitMasterConfigSchema } from "./git-master" @@ -38,6 +39,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ claude_code: ClaudeCodeConfigSchema.optional(), sisyphus_agent: SisyphusAgentConfigSchema.optional(), comment_checker: CommentCheckerConfigSchema.optional(), + keyword_detector: KeywordDetectorConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), auto_update: z.boolean().optional(), skills: SkillsConfigSchema.optional(), diff --git a/src/hooks/keyword-detector/constants.ts b/src/hooks/keyword-detector/constants.ts index 6c9bec4a9b..da1827bfa4 100644 --- a/src/hooks/keyword-detector/constants.ts +++ b/src/hooks/keyword-detector/constants.ts @@ -1,7 +1,6 @@ export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g export const INLINE_CODE_PATTERN = /`[^`]+`/g -// Re-export from submodules export { isPlannerAgent, getUltraworkMessage } from "./ultrawork" export { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search" export { ANALYZE_PATTERN, ANALYZE_MESSAGE } from "./analyze" @@ -15,6 +14,12 @@ export type KeywordDetector = { message: string | ((agentName?: string, modelID?: string) => string) } +const DEFAULT_ULTRAWORK_ALIASES = ["ultrawork", "ulw"] + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + export const KEYWORD_DETECTORS: KeywordDetector[] = [ { pattern: /\b(ultrawork|ulw)\b/i, @@ -29,3 +34,22 @@ export const KEYWORD_DETECTORS: KeywordDetector[] = [ message: ANALYZE_MESSAGE, }, ] + +export function createKeywordDetectors(extraUltraworkAliases?: string[]): KeywordDetector[] { + const allAliases = extraUltraworkAliases + ? [ + ...DEFAULT_ULTRAWORK_ALIASES, + ...extraUltraworkAliases + .map((a) => a.trim()) + .filter((a) => a.length > 0) + .map(escapeRegExp), + ] + : DEFAULT_ULTRAWORK_ALIASES + const ultraworkPattern = new RegExp(`\\b(${allAliases.join("|")})\\b`, "i") + + return [ + { pattern: ultraworkPattern, message: getUltraworkMessage }, + { pattern: SEARCH_PATTERN, message: SEARCH_MESSAGE }, + { pattern: ANALYZE_PATTERN, message: ANALYZE_MESSAGE }, + ] +} diff --git a/src/hooks/keyword-detector/detector.ts b/src/hooks/keyword-detector/detector.ts index 0acde04f8d..436320261c 100644 --- a/src/hooks/keyword-detector/detector.ts +++ b/src/hooks/keyword-detector/detector.ts @@ -2,6 +2,7 @@ import { KEYWORD_DETECTORS, CODE_BLOCK_PATTERN, INLINE_CODE_PATTERN, + type KeywordDetector, } from "./constants" export interface DetectedKeyword { @@ -13,9 +14,6 @@ export function removeCodeBlocks(text: string): string { return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "") } -/** - * Resolves message to string, handling both static strings and dynamic functions. - */ function resolveMessage( message: string | ((agentName?: string, modelID?: string) => string), agentName?: string, @@ -24,17 +22,27 @@ function resolveMessage( return typeof message === "function" ? message(agentName, modelID) : message } -export function detectKeywords(text: string, agentName?: string, modelID?: string): string[] { +export function detectKeywords( + text: string, + agentName?: string, + modelID?: string, + detectors: KeywordDetector[] = KEYWORD_DETECTORS, +): string[] { const textWithoutCode = removeCodeBlocks(text) - return KEYWORD_DETECTORS.filter(({ pattern }) => + return detectors.filter(({ pattern }) => pattern.test(textWithoutCode) ).map(({ message }) => resolveMessage(message, agentName, modelID)) } -export function detectKeywordsWithType(text: string, agentName?: string, modelID?: string): DetectedKeyword[] { +export function detectKeywordsWithType( + text: string, + agentName?: string, + modelID?: string, + detectors: KeywordDetector[] = KEYWORD_DETECTORS, +): DetectedKeyword[] { const textWithoutCode = removeCodeBlocks(text) const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"] - return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({ + return detectors.map(({ pattern, message }, index) => ({ matches: pattern.test(textWithoutCode), type: types[index], message: resolveMessage(message, agentName, modelID), diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index c6b7e9dfbd..e9415fec89 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -1,6 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { detectKeywordsWithType, extractPromptText } from "./detector" -import { isPlannerAgent } from "./constants" +import { isPlannerAgent, createKeywordDetectors } from "./constants" import { log } from "../../shared" import { isSystemDirective, @@ -12,8 +12,16 @@ import { subagentSessions, } from "../../features/claude-code-session-state" import type { ContextCollector } from "../../features/context-injector" - -export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) { +import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector" + +export function createKeywordDetectorHook( + ctx: PluginInput, + _collector?: ContextCollector, + keywordDetectorConfig?: KeywordDetectorConfig, +) { + const customDetectors = keywordDetectorConfig?.extra_ultrawork_aliases?.length + ? createKeywordDetectors(keywordDetectorConfig.extra_ultrawork_aliases) + : undefined return { "chat.message": async ( input: { @@ -39,7 +47,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context // Remove system-reminder content to prevent automated system messages from triggering mode keywords const cleanText = removeSystemReminders(promptText) const modelID = input.model?.modelID - let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID) + let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, customDetectors) if (isPlannerAgent(currentAgent)) { detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 558f103dec..2fdcfefcc6 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" -import { createKeywordDetectorHook } from "./index" +import { createKeywordDetectorHook, createKeywordDetectors } from "./index" import { setMainSession, updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state" import { ContextCollector } from "../../features/context-injector" import * as sharedModule from "../../shared" @@ -746,3 +746,199 @@ describe("keyword-detector agent-specific ultrawork messages", () => { expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") }) }) + +describe("createKeywordDetectors factory", () => { + test("should include default aliases when called with no args", () => { + //#given - no extra aliases + const detectors = createKeywordDetectors() + + //#when - testing the ultrawork pattern (first detector) + const ultraworkPattern = detectors[0].pattern + + //#then - should match built-in aliases + expect(ultraworkPattern.test("ultrawork")).toBe(true) + expect(ultraworkPattern.test("ulw")).toBe(true) + expect(ultraworkPattern.test("lfg")).toBe(false) + }) + + test("should include custom aliases alongside defaults", () => { + //#given - extra alias "lfg" + const detectors = createKeywordDetectors(["lfg"]) + + //#when - testing the ultrawork pattern + const ultraworkPattern = detectors[0].pattern + + //#then - should match both built-in and custom aliases + expect(ultraworkPattern.test("ultrawork")).toBe(true) + expect(ultraworkPattern.test("ulw")).toBe(true) + expect(ultraworkPattern.test("lfg")).toBe(true) + }) + + test("should respect word boundaries for custom aliases", () => { + //#given - extra alias "lfg" + const detectors = createKeywordDetectors(["lfg"]) + const ultraworkPattern = detectors[0].pattern + + //#when - testing partial matches + //#then - should NOT match substrings + expect(ultraworkPattern.test("lfg do this")).toBe(true) + expect(ultraworkPattern.test("configuring stuff")).toBe(false) + }) + + test("should escape regex special characters in custom aliases", () => { + //#given - alias with regex special chars + const detectors = createKeywordDetectors(["go+go"]) + + //#when - testing the pattern + const ultraworkPattern = detectors[0].pattern + + //#then - should match literal "go+go", not regex "go+go" + expect(ultraworkPattern.test("go+go")).toBe(true) + expect(ultraworkPattern.test("gooogo")).toBe(false) + }) + + test("should filter out empty and whitespace-only aliases", () => { + //#given - aliases containing empty strings and whitespace + const detectors = createKeywordDetectors(["", " ", "lfg", ""]) + + //#when - testing the ultrawork pattern + const ultraworkPattern = detectors[0].pattern + + //#then - should match valid aliases but not produce overly broad regex + expect(ultraworkPattern.test("ultrawork")).toBe(true) + expect(ultraworkPattern.test("ulw")).toBe(true) + expect(ultraworkPattern.test("lfg")).toBe(true) + expect(ultraworkPattern.test("hello")).toBe(false) + expect(ultraworkPattern.test("some random text")).toBe(false) + }) + + test("should trim whitespace from aliases", () => { + //#given - alias with surrounding whitespace + const detectors = createKeywordDetectors([" lfg "]) + + //#when - testing the ultrawork pattern + const ultraworkPattern = detectors[0].pattern + + //#then - should match trimmed alias + expect(ultraworkPattern.test("lfg")).toBe(true) + }) + + test("should return all three detector types", () => { + //#given - factory called with custom aliases + const detectors = createKeywordDetectors(["lfg"]) + + //#then - should have ultrawork, search, and analyze detectors + expect(detectors).toHaveLength(3) + }) +}) + +describe("keyword-detector custom aliases integration", () => { + let logSpy: ReturnType + + beforeEach(() => { + _resetForTesting() + logSpy = spyOn(sharedModule, "log").mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy?.mockRestore() + _resetForTesting() + }) + + function createMockPluginInput(options: { toastCalls?: string[] } = {}) { + const toastCalls = options.toastCalls ?? [] + return { + client: { + tui: { + showToast: async (opts: any) => { + toastCalls.push(opts.body.title) + }, + }, + }, + } as any + } + + test("should trigger ultrawork mode with custom alias 'lfg'", async () => { + //#given - hook configured with extra alias "lfg" + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook( + createMockPluginInput({ toastCalls }), + undefined, + { extra_ultrawork_aliases: ["lfg"] }, + ) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "lfg ship this feature" }], + } + + //#when - message with custom alias is processed + await hook["chat.message"]({ sessionID: "test-session" }, output) + + //#then - ultrawork mode should activate + expect(output.message.variant).toBe("max") + expect(toastCalls).toContain("Ultrawork Mode Activated") + const textPart = output.parts.find(p => p.type === "text") + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + }) + + test("should NOT trigger ultrawork on partial match of custom alias", async () => { + //#given - hook with alias "lfg" + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook( + createMockPluginInput({ toastCalls }), + undefined, + { extra_ultrawork_aliases: ["lfg"] }, + ) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "configuring the deployment" }], + } + + //#when - message with partial match is processed + await hook["chat.message"]({ sessionID: "test-session" }, output) + + //#then - ultrawork mode should NOT activate + expect(output.message.variant).toBeUndefined() + expect(toastCalls).not.toContain("Ultrawork Mode Activated") + }) + + test("should still trigger on built-in aliases when custom aliases are configured", async () => { + //#given - hook with custom alias, but using built-in "ulw" + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook( + createMockPluginInput({ toastCalls }), + undefined, + { extra_ultrawork_aliases: ["lfg"] }, + ) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw do this task" }], + } + + //#when - message with built-in alias is processed + await hook["chat.message"]({ sessionID: "test-session" }, output) + + //#then - ultrawork mode should activate via built-in alias + expect(output.message.variant).toBe("max") + expect(toastCalls).toContain("Ultrawork Mode Activated") + }) + + test("should work without config (backward compatible)", async () => { + //#given - hook with no config (original behavior) + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook( + createMockPluginInput({ toastCalls }), + ) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork implement this" }], + } + + //#when - message with built-in alias is processed + await hook["chat.message"]({ sessionID: "test-session" }, output) + + //#then - ultrawork mode should still work + expect(output.message.variant).toBe("max") + expect(toastCalls).toContain("Ultrawork Mode Activated") + }) +}) diff --git a/src/plugin/hooks/create-transform-hooks.ts b/src/plugin/hooks/create-transform-hooks.ts index 8001d0ab1e..b6b9aa010b 100644 --- a/src/plugin/hooks/create-transform-hooks.ts +++ b/src/plugin/hooks/create-transform-hooks.ts @@ -40,7 +40,7 @@ export function createTransformHooks(args: { const keywordDetector = isHookEnabled("keyword-detector") ? safeCreateHook( "keyword-detector", - () => createKeywordDetectorHook(ctx, contextCollector), + () => createKeywordDetectorHook(ctx, contextCollector, pluginConfig.keyword_detector), { enabled: safeHookEnabled }, ) : null