feat(keyword-detector): add per-keyword disable config
Adds keyword_detector.disabled_keywords config so users can opt out of
specific keyword detectors individually without disabling the entire
keyword-detector hook. Allowed values: 'ultrawork', 'search', 'analyze',
'team'. Default empty/missing -> all four detectors active (no behavior
change for existing configs).
Motivation: an audit revealed search and analyze patterns trigger on
~30-60% of normal conversational user messages (e.g. 'how to', 'why is',
'show me', '왜', '어떻게'). The disable list is the immediate kill switch
while the patterns themselves are tightened in a separate PR.
Schema follows the existing per-feature config block convention shared
by team_mode, ralph_loop, runtime_fallback, and comment_checker. The
KeywordType enum (z.enum) lives next to the config schema and is
re-imported by the detector to keep the union type in lockstep with the
schema.
Threading:
pluginConfig.keyword_detector
-> create-transform-hooks.ts (factory wiring)
-> createKeywordDetectorHook(config)
-> detectKeywordsWithType(text, agent, model, disabledKeywords)
-> Set-based filter at the source-of-truth detector
Adds 8 regression tests covering per-keyword disable, multi-keyword
disable, partial disable (one keyword off, another still firing),
ultrawork toast suppression, undefined config, and empty array.
This commit is contained in:
@@ -22,4 +22,6 @@ export type {
|
||||
ModelCapabilitiesConfig,
|
||||
FallbackModels,
|
||||
TeamModeConfig,
|
||||
KeywordDetectorConfig,
|
||||
KeywordType,
|
||||
} from "./schema"
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./schema/fallback-models"
|
||||
export * from "./schema/git-env-prefix"
|
||||
export * from "./schema/git-master"
|
||||
export * from "./schema/hooks"
|
||||
export * from "./schema/keyword-detector"
|
||||
export * from "./schema/model-capabilities"
|
||||
export * from "./schema/notification"
|
||||
export * from "./schema/oh-my-opencode-config"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const KeywordTypeSchema = z.enum(["ultrawork", "search", "analyze", "team"])
|
||||
export type KeywordType = z.infer<typeof KeywordTypeSchema>
|
||||
|
||||
export const KeywordDetectorConfigSchema = z.object({
|
||||
disabled_keywords: z.array(KeywordTypeSchema).optional(),
|
||||
})
|
||||
|
||||
export type KeywordDetectorConfig = z.infer<typeof KeywordDetectorConfigSchema>
|
||||
@@ -12,6 +12,7 @@ import { CommentCheckerConfigSchema } from "./comment-checker"
|
||||
import { BuiltinCommandNameSchema } from "./commands"
|
||||
import { ExperimentalConfigSchema } from "./experimental"
|
||||
import { GitMasterConfigSchema } from "./git-master"
|
||||
import { KeywordDetectorConfigSchema } from "./keyword-detector"
|
||||
import { NotificationConfigSchema } from "./notification"
|
||||
import { OpenClawConfigSchema } from "./openclaw"
|
||||
import { ModelCapabilitiesConfigSchema } from "./model-capabilities"
|
||||
@@ -65,6 +66,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
model_capabilities: ModelCapabilitiesConfigSchema.optional(),
|
||||
openclaw: OpenClawConfigSchema.optional(),
|
||||
team_mode: TeamModeConfigSchema.optional(),
|
||||
/** Per-keyword disable list for the keyword-detector transform hook. Allowed values: "ultrawork", "search", "analyze", "team". */
|
||||
keyword_detector: KeywordDetectorConfigSchema.optional(),
|
||||
babysitting: BabysittingConfigSchema.optional(),
|
||||
git_master: GitMasterConfigSchema.default({
|
||||
commit_footer: true,
|
||||
|
||||
@@ -47,11 +47,24 @@ chat.message (user input)
|
||||
→ extractPromptText(parts)
|
||||
→ isSystemDirective? → skip
|
||||
→ removeSystemReminders(text) # strip <SYSTEM_REMINDER> blocks
|
||||
→ detectKeywordsWithType(cleanText, agentName, modelID)
|
||||
→ detectKeywordsWithType(cleanText, agentName, modelID, disabledKeywords)
|
||||
→ isPlannerAgent(agentName)? → filter out ultrawork
|
||||
→ for each detected keyword: inject mode message into output
|
||||
```
|
||||
|
||||
## CONFIG
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"keyword_detector": {
|
||||
// Skip injection for any keyword in this list. Allowed: "ultrawork", "search", "analyze", "team".
|
||||
"disabled_keywords": ["search", "analyze"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Default: empty/missing → all four detectors active. Schema lives at [src/config/schema/keyword-detector.ts](../../config/schema/keyword-detector.ts).
|
||||
|
||||
## GUARDS
|
||||
|
||||
- **System directive skip**: Messages tagged as system directives are not scanned (prevents infinite loops)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { KeywordType } from "../../config/schema/keyword-detector"
|
||||
import {
|
||||
KEYWORD_DETECTORS,
|
||||
CODE_BLOCK_PATTERN,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
} from "./constants"
|
||||
|
||||
export interface DetectedKeyword {
|
||||
type: "ultrawork" | "search" | "analyze" | "team"
|
||||
type: KeywordType
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -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,22 +22,32 @@ function resolveMessage(
|
||||
return typeof message === "function" ? message(agentName, modelID) : message
|
||||
}
|
||||
|
||||
export function detectKeywords(text: string, agentName?: string, modelID?: string): string[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
return KEYWORD_DETECTORS.filter(({ pattern }) =>
|
||||
pattern.test(textWithoutCode)
|
||||
).map(({ message }) => resolveMessage(message, agentName, modelID))
|
||||
export function detectKeywords(
|
||||
text: string,
|
||||
agentName?: string,
|
||||
modelID?: string,
|
||||
disabledKeywords?: ReadonlyArray<KeywordType>,
|
||||
): string[] {
|
||||
return detectKeywordsWithType(text, agentName, modelID, disabledKeywords).map(
|
||||
({ message }) => message,
|
||||
)
|
||||
}
|
||||
|
||||
export function detectKeywordsWithType(text: string, agentName?: string, modelID?: string): DetectedKeyword[] {
|
||||
export function detectKeywordsWithType(
|
||||
text: string,
|
||||
agentName?: string,
|
||||
modelID?: string,
|
||||
disabledKeywords?: ReadonlyArray<KeywordType>,
|
||||
): DetectedKeyword[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
const types: Array<DetectedKeyword["type"]> = ["ultrawork", "search", "analyze", "team"]
|
||||
const types: Array<KeywordType> = ["ultrawork", "search", "analyze", "team"]
|
||||
const disabled = new Set<KeywordType>(disabledKeywords ?? [])
|
||||
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
|
||||
matches: pattern.test(textWithoutCode),
|
||||
type: types[index],
|
||||
message: resolveMessage(message, agentName, modelID),
|
||||
}))
|
||||
.filter((result) => result.matches)
|
||||
.filter((result) => result.matches && !disabled.has(result.type))
|
||||
.map(({ type, message }) => ({ type, message }))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector"
|
||||
import { detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { isPlannerAgent, isNonOmoAgent } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
@@ -17,8 +18,10 @@ import type { RalphLoopHook } from "../ralph-loop"
|
||||
export function createKeywordDetectorHook(
|
||||
ctx: PluginInput,
|
||||
_collector?: ContextCollector,
|
||||
_ralphLoop?: Pick<RalphLoopHook, "startLoop">
|
||||
_ralphLoop?: Pick<RalphLoopHook, "startLoop">,
|
||||
config?: KeywordDetectorConfig,
|
||||
) {
|
||||
const disabledKeywords = config?.disabled_keywords
|
||||
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
|
||||
if (typeof message["variant"] === "string") {
|
||||
return message["variant"]
|
||||
@@ -59,7 +62,7 @@ export function createKeywordDetectorHook(
|
||||
// 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, disabledKeywords)
|
||||
|
||||
if (isPlannerAgent(currentAgent)) {
|
||||
const preFilterCount = detectedKeywords.length
|
||||
|
||||
@@ -1041,3 +1041,237 @@ describe("keyword-detector team mode", () => {
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector disabled_keywords config", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => {
|
||||
// given - keyword detector with search disabled
|
||||
const sessionID = "search-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the bug in the code" }],
|
||||
}
|
||||
|
||||
// when - search keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - search-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search for the bug in the code")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject analyze-mode when disabled_keywords includes 'analyze'", async () => {
|
||||
// given - keyword detector with analyze disabled
|
||||
const sessionID = "analyze-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["analyze"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "how to do this" }],
|
||||
}
|
||||
|
||||
// when - analyze keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("how to do this")
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject team-mode when disabled_keywords includes 'team'", async () => {
|
||||
// given - keyword detector with team disabled
|
||||
const sessionID = "team-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["team"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "let's use team mode for this" }],
|
||||
}
|
||||
|
||||
// when - team keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("let's use team mode for this")
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject ultrawork message AND not show toast when disabled_keywords includes 'ultrawork'", async () => {
|
||||
// given - keyword detector with ultrawork disabled
|
||||
const sessionID = "ultrawork-disabled-session"
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput({ toastCalls }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["ultrawork"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do this task" }],
|
||||
}
|
||||
|
||||
// when - ultrawork keyword would normally trigger toast + injection
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - neither toast nor injection should occur
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("ultrawork do this task")
|
||||
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should disable multiple keywords simultaneously when listed together", async () => {
|
||||
// given - keyword detector with both search and analyze disabled
|
||||
const sessionID = "multi-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search", "analyze"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search and analyze the codebase" }],
|
||||
}
|
||||
|
||||
// when - both search and analyze would normally fire
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - neither mode should inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search and analyze the codebase")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
})
|
||||
|
||||
test("should let other keywords through when only one is disabled", async () => {
|
||||
// given - keyword detector with only search disabled, but message contains both search and analyze triggers
|
||||
const sessionID = "partial-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search and analyze the codebase" }],
|
||||
}
|
||||
|
||||
// when - both keywords match but only search is disabled
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze should still inject, search should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).toContain("[analyze-mode]")
|
||||
expect(textPart!.text).toContain("search and analyze the codebase")
|
||||
})
|
||||
|
||||
test("should behave normally (all keywords enabled) when config is undefined", async () => {
|
||||
// given - keyword detector with no config (regression test for backward compat)
|
||||
const sessionID = "no-config-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the answer" }],
|
||||
}
|
||||
|
||||
// when - search keyword fires with no config
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - search-mode should inject as usual
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[search-mode]")
|
||||
})
|
||||
|
||||
test("should behave normally when disabled_keywords is an empty array", async () => {
|
||||
// given - keyword detector with empty disable list
|
||||
const sessionID = "empty-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: [] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "investigate this issue" }],
|
||||
}
|
||||
|
||||
// when - analyze keyword fires with empty disable list
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze-mode should still inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[analyze-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,13 @@ export function createTransformHooks(args: {
|
||||
const keywordDetector = isHookEnabled("keyword-detector")
|
||||
? safeCreateHook(
|
||||
"keyword-detector",
|
||||
() => createKeywordDetectorHook(ctx, contextCollector, ralphLoop ?? undefined),
|
||||
() =>
|
||||
createKeywordDetectorHook(
|
||||
ctx,
|
||||
contextCollector,
|
||||
ralphLoop ?? undefined,
|
||||
pluginConfig.keyword_detector,
|
||||
),
|
||||
{ enabled: safeHookEnabled },
|
||||
)
|
||||
: null
|
||||
|
||||
Reference in New Issue
Block a user