Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
26 changes: 25 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,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 },
]
}
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
198 changes: 197 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,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<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
Loading