refactor(keyword-detector): decouple from claude-code-hooks via ContextCollector pipeline
- keyword-detector now registers keywords to ContextCollector - context-injector consumes and injects via chat.message hook - Removed keyword detection logic from claude-code-hooks - Hook order: keyword-detector → context-injector → claude-code-hooks - ultrawork now works even when claude-code-hooks is disabled
This commit is contained in:
parent
1c262a65fe
commit
ce5315fbd0
@ -133,7 +133,7 @@ describe("createContextInjectorHook", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("chat.message handler", () => {
|
describe("chat.message handler", () => {
|
||||||
it("is a no-op (context injection moved to messages transform)", async () => {
|
it("injects pending context into output parts", async () => {
|
||||||
// #given
|
// #given
|
||||||
const hook = createContextInjectorHook(collector)
|
const hook = createContextInjectorHook(collector)
|
||||||
const sessionID = "ses_hook1"
|
const sessionID = "ses_hook1"
|
||||||
@ -152,8 +152,9 @@ describe("createContextInjectorHook", () => {
|
|||||||
await hook["chat.message"](input, output)
|
await hook["chat.message"](input, output)
|
||||||
|
|
||||||
// #then
|
// #then
|
||||||
expect(output.parts[0].text).toBe("User message")
|
expect(output.parts[0].text).toContain("Hook context")
|
||||||
expect(collector.hasPending(sessionID)).toBe(true)
|
expect(output.parts[0].text).toContain("User message")
|
||||||
|
expect(collector.hasPending(sessionID)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("does nothing when no pending context", async () => {
|
it("does nothing when no pending context", async () => {
|
||||||
|
|||||||
@ -52,10 +52,16 @@ interface ChatMessageOutput {
|
|||||||
export function createContextInjectorHook(collector: ContextCollector) {
|
export function createContextInjectorHook(collector: ContextCollector) {
|
||||||
return {
|
return {
|
||||||
"chat.message": async (
|
"chat.message": async (
|
||||||
_input: ChatMessageInput,
|
input: ChatMessageInput,
|
||||||
_output: ChatMessageOutput
|
output: ChatMessageOutput
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
void collector
|
const result = injectPendingContext(collector, input.sessionID, output.parts)
|
||||||
|
if (result.injected) {
|
||||||
|
log("[context-injector] Injected pending context via chat.message", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
contextLength: result.contextLength,
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,6 @@ import { cacheToolInput, getToolInput } from "./tool-input-cache"
|
|||||||
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
|
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
|
||||||
import type { PluginConfig } from "./types"
|
import type { PluginConfig } from "./types"
|
||||||
import { log, isHookDisabled } from "../../shared"
|
import { log, isHookDisabled } from "../../shared"
|
||||||
import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector"
|
|
||||||
import type { ContextCollector } from "../../features/context-injector"
|
import type { ContextCollector } from "../../features/context-injector"
|
||||||
|
|
||||||
const sessionFirstMessageProcessed = new Set<string>()
|
const sessionFirstMessageProcessed = new Set<string>()
|
||||||
@ -142,25 +141,9 @@ export function createClaudeCodeHooksHook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const keywordMessages: string[] = []
|
if (result.messages.length > 0) {
|
||||||
if (!config.keywordDetectorDisabled) {
|
const hookContent = result.messages.join("\n\n")
|
||||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent)
|
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||||
keywordMessages.push(...detectedKeywords.map((k) => k.message))
|
|
||||||
|
|
||||||
if (keywordMessages.length > 0) {
|
|
||||||
log("[claude-code-hooks] Detected keywords", {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
keywordCount: keywordMessages.length,
|
|
||||||
types: detectedKeywords.map((k) => k.type),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allMessages = [...keywordMessages, ...result.messages]
|
|
||||||
|
|
||||||
if (allMessages.length > 0) {
|
|
||||||
const hookContent = allMessages.join("\n\n")
|
|
||||||
log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
|
||||||
|
|
||||||
if (isFirstMessage) {
|
if (isFirstMessage) {
|
||||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||||
|
|||||||
@ -1,7 +1,95 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||||
import { createKeywordDetectorHook } from "./index"
|
import { createKeywordDetectorHook } from "./index"
|
||||||
import { setMainSession } from "../../features/claude-code-session-state"
|
import { setMainSession } from "../../features/claude-code-session-state"
|
||||||
|
import { ContextCollector } from "../../features/context-injector"
|
||||||
import * as sharedModule from "../../shared"
|
import * as sharedModule from "../../shared"
|
||||||
|
import * as sessionState from "../../features/claude-code-session-state"
|
||||||
|
|
||||||
|
describe("keyword-detector registers to ContextCollector", () => {
|
||||||
|
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||||
|
let logSpy: ReturnType<typeof spyOn>
|
||||||
|
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logCalls = []
|
||||||
|
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||||
|
logCalls.push({ msg, data })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logSpy?.mockRestore()
|
||||||
|
getMainSessionSpy?.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
function createMockPluginInput() {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
tui: {
|
||||||
|
showToast: async () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should register ultrawork keyword to ContextCollector", async () => {
|
||||||
|
// #given - a fresh ContextCollector and keyword-detector hook
|
||||||
|
const collector = new ContextCollector()
|
||||||
|
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||||
|
const sessionID = "test-session-123"
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "ultrawork do something" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when - keyword detection runs
|
||||||
|
await hook["chat.message"]({ sessionID }, output)
|
||||||
|
|
||||||
|
// #then - ultrawork context should be registered in collector
|
||||||
|
expect(collector.hasPending(sessionID)).toBe(true)
|
||||||
|
const pending = collector.getPending(sessionID)
|
||||||
|
expect(pending.entries.length).toBeGreaterThan(0)
|
||||||
|
expect(pending.entries[0].source).toBe("keyword-detector")
|
||||||
|
expect(pending.entries[0].id).toBe("keyword-ultrawork")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should register search keyword to ContextCollector", async () => {
|
||||||
|
// #given - mock getMainSessionID to return our session (isolate from global state)
|
||||||
|
const collector = new ContextCollector()
|
||||||
|
const sessionID = "search-test-session"
|
||||||
|
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||||
|
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "search for the bug" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when - keyword detection runs
|
||||||
|
await hook["chat.message"]({ sessionID }, output)
|
||||||
|
|
||||||
|
// #then - search context should be registered in collector
|
||||||
|
expect(collector.hasPending(sessionID)).toBe(true)
|
||||||
|
const pending = collector.getPending(sessionID)
|
||||||
|
expect(pending.entries.some((e) => e.id === "keyword-search")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should NOT register to collector when no keywords detected", async () => {
|
||||||
|
// #given - no keywords in message
|
||||||
|
const collector = new ContextCollector()
|
||||||
|
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||||
|
const sessionID = "test-session"
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "just a normal message" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when - keyword detection runs
|
||||||
|
await hook["chat.message"]({ sessionID }, output)
|
||||||
|
|
||||||
|
// #then - nothing should be registered
|
||||||
|
expect(collector.hasPending(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("keyword-detector session filtering", () => {
|
describe("keyword-detector session filtering", () => {
|
||||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||||
|
|||||||
@ -2,12 +2,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { getMainSessionID } from "../../features/claude-code-session-state"
|
import { getMainSessionID } from "../../features/claude-code-session-state"
|
||||||
|
import type { ContextCollector } from "../../features/context-injector"
|
||||||
|
|
||||||
export * from "./detector"
|
export * from "./detector"
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
||||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextCollector) {
|
||||||
return {
|
return {
|
||||||
"chat.message": async (
|
"chat.message": async (
|
||||||
input: {
|
input: {
|
||||||
@ -28,8 +29,6 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only ultrawork keywords work in non-main sessions
|
|
||||||
// Other keywords (search, analyze, etc.) only work in main sessions
|
|
||||||
const mainSessionID = getMainSessionID()
|
const mainSessionID = getMainSessionID()
|
||||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||||
|
|
||||||
@ -64,6 +63,17 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collector) {
|
||||||
|
for (const keyword of detectedKeywords) {
|
||||||
|
collector.register(input.sessionID, {
|
||||||
|
id: `keyword-${keyword.type}`,
|
||||||
|
source: "keyword-detector",
|
||||||
|
content: keyword.message,
|
||||||
|
priority: keyword.type === "ultrawork" ? "critical" : "high",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
types: detectedKeywords.map((k) => k.type),
|
types: detectedKeywords.map((k) => k.type),
|
||||||
|
|||||||
@ -165,7 +165,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const keywordDetector = isHookEnabled("keyword-detector")
|
const keywordDetector = isHookEnabled("keyword-detector")
|
||||||
? createKeywordDetectorHook(ctx)
|
? createKeywordDetectorHook(ctx, contextCollector)
|
||||||
: null;
|
: null;
|
||||||
const contextInjector = createContextInjectorHook(contextCollector);
|
const contextInjector = createContextInjectorHook(contextCollector);
|
||||||
const contextInjectorMessagesTransform =
|
const contextInjectorMessagesTransform =
|
||||||
@ -313,9 +313,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"chat.message": async (input, output) => {
|
"chat.message": async (input, output) => {
|
||||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
|
||||||
await keywordDetector?.["chat.message"]?.(input, output);
|
await keywordDetector?.["chat.message"]?.(input, output);
|
||||||
await contextInjector["chat.message"]?.(input, output);
|
await contextInjector["chat.message"]?.(input, output);
|
||||||
|
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||||
await startWork?.["chat.message"]?.(input, output);
|
await startWork?.["chat.message"]?.(input, output);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user