Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/config/schema/keyword-detector.ts
Original file line number Diff line number Diff line change
@@ -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<typeof KeywordDetectorConfigSchema>
2 changes: 2 additions & 0 deletions src/config/schema/oh-my-opencode-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down
20 changes: 19 additions & 1 deletion src/hooks/keyword-detector/constants.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand All @@ -29,3 +34,16 @@ export const KEYWORD_DETECTORS: KeywordDetector[] = [
message: ANALYZE_MESSAGE,
},
]

export function createKeywordDetectors(extraUltraworkAliases?: string[]): KeywordDetector[] {
const allAliases = extraUltraworkAliases
? [...DEFAULT_ULTRAWORK_ALIASES, ...extraUltraworkAliases.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 },
]
}
22 changes: 15 additions & 7 deletions src/hooks/keyword-detector/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
KEYWORD_DETECTORS,
CODE_BLOCK_PATTERN,
INLINE_CODE_PATTERN,
type KeywordDetector,
} from "./constants"

export interface DetectedKeyword {
Expand All @@ -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,
Expand All @@ -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),
Expand Down
16 changes: 12 additions & 4 deletions src/hooks/keyword-detector/hook.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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: {
Expand All @@ -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")
Expand Down
172 changes: 171 additions & 1 deletion src/hooks/keyword-detector/index.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -746,3 +746,173 @@ 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 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<typeof spyOn>

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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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")
})
})
2 changes: 1 addition & 1 deletion src/plugin/hooks/create-transform-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down