Skip to content

Commit 192cbf4

Browse files
marlon-costa-dcMarlon Costa
authored andcommitted
feat(mcb): make MCB integration config-gated and disabled by default
- Add MCB optional layer config schema (src/config/schema/mcb.ts) - Add config gate logic to disable MCB by default (src/features/mcb-integration/config-gate.ts) - Add 'mcb' config field to OhMyOpenCodeConfigSchema - Wire initialization in src/index.ts to lock MCB availability based on config - Add wisdom-capture hook (uses MCB fallback) and ambiguity-detector hook - Fix type errors in wisdom-capture tests
1 parent 44badef commit 192cbf4

File tree

22 files changed

+563
-1
lines changed

22 files changed

+563
-1
lines changed

src/config/schema/hooks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const HookNameSchema = z.enum([
2121
"auto-update-checker",
2222
"startup-toast",
2323
"keyword-detector",
24+
"ambiguity-detector",
2425
"agent-usage-reminder",
2526
"non-interactive-env",
2627
"interactive-bash-session",
@@ -46,6 +47,7 @@ export const HookNameSchema = z.enum([
4647
"tasks-todowrite-disabler",
4748
"write-existing-file-guard",
4849
"anthropic-effort",
50+
"wisdom-capture",
4951
])
5052

5153
export type HookName = z.infer<typeof HookNameSchema>

src/config/schema/mcb.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { z } from "zod"
2+
3+
export const McbConfigSchema = z.object({
4+
enabled: z.boolean().optional(),
5+
url: z.string().url().optional(),
6+
default_collection: z.string().optional(),
7+
auto_index: z.boolean().optional(),
8+
tools: z
9+
.object({
10+
search: z.boolean().optional(),
11+
memory: z.boolean().optional(),
12+
index: z.boolean().optional(),
13+
validate: z.boolean().optional(),
14+
vcs: z.boolean().optional(),
15+
session: z.boolean().optional(),
16+
})
17+
.optional(),
18+
})
19+
20+
export type McbConfig = z.infer<typeof McbConfigSchema>

src/config/schema/oh-my-opencode-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { BuiltinCommandNameSchema } from "./commands"
1212
import { ExperimentalConfigSchema } from "./experimental"
1313
import { GitMasterConfigSchema } from "./git-master"
1414
import { HookNameSchema } from "./hooks"
15+
import { McbConfigSchema } from "./mcb"
1516
import { NotificationConfigSchema } from "./notification"
1617
import { RalphLoopConfigSchema } from "./ralph-loop"
1718
import { SkillsConfigSchema } from "./skills"
@@ -50,6 +51,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
5051
websearch: WebsearchConfigSchema.optional(),
5152
tmux: TmuxConfigSchema.optional(),
5253
sisyphus: SisyphusConfigSchema.optional(),
54+
mcb: McbConfigSchema.optional(),
5355
/** Migration history to prevent re-applying migrations (e.g., model version upgrades) */
5456
_migrations: z.array(z.string()).optional(),
5557
})

src/features/mcb-integration/availability.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import type { McbAvailabilityStatus, McbToolAvailability } from "./types"
22

33
let cachedStatus: McbAvailabilityStatus | null = null
4+
let configLocked = false
45
const CACHE_TTL_MS = 60_000
56

7+
export function lockMcbAvailability(): void {
8+
configLocked = true
9+
}
10+
611
export function getMcbAvailability(): McbAvailabilityStatus {
7-
if (cachedStatus && Date.now() - cachedStatus.checkedAt < CACHE_TTL_MS) {
12+
if (cachedStatus && (configLocked || Date.now() - cachedStatus.checkedAt < CACHE_TTL_MS)) {
813
return cachedStatus
914
}
1015

@@ -38,4 +43,5 @@ export function markMcbUnavailable(tool?: keyof McbToolAvailability): void {
3843

3944
export function resetMcbAvailability(): void {
4045
cachedStatus = null
46+
configLocked = false
4147
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2+
import { initializeMcbFromConfig } from "./config-gate"
3+
import { getMcbAvailability, resetMcbAvailability } from "./availability"
4+
import type { McbConfig } from "../../config/schema/mcb"
5+
6+
describe("mcb-integration/config-gate", () => {
7+
beforeEach(() => {
8+
resetMcbAvailability()
9+
})
10+
11+
afterEach(() => {
12+
resetMcbAvailability()
13+
})
14+
15+
//#given no mcb config (undefined)
16+
//#when initializeMcbFromConfig is called
17+
//#then mcb is completely unavailable and locked
18+
it("disables MCB when config is missing", () => {
19+
initializeMcbFromConfig(undefined)
20+
21+
const status = getMcbAvailability()
22+
expect(status.available).toBe(false)
23+
24+
// Verify it's locked (subsequent calls don't reset it)
25+
// We can't directly check 'locked' state, but we can check if it remains unavailable
26+
// even if we try to access it again
27+
const status2 = getMcbAvailability()
28+
expect(status2.available).toBe(false)
29+
})
30+
31+
//#given mcb config with enabled: false
32+
//#when initializeMcbFromConfig is called
33+
//#then mcb is completely unavailable and locked
34+
it("disables MCB when enabled is false", () => {
35+
const config: McbConfig = {
36+
enabled: false,
37+
}
38+
initializeMcbFromConfig(config)
39+
40+
const status = getMcbAvailability()
41+
expect(status.available).toBe(false)
42+
})
43+
44+
//#given mcb config with enabled: true
45+
//#when initializeMcbFromConfig is called
46+
//#then mcb is available with all tools enabled by default
47+
it("enables MCB when enabled is true", () => {
48+
const config: McbConfig = {
49+
enabled: true,
50+
url: "http://localhost:3000",
51+
}
52+
initializeMcbFromConfig(config)
53+
54+
const status = getMcbAvailability()
55+
expect(status.available).toBe(true)
56+
expect(status.tools.search).toBe(true)
57+
expect(status.tools.memory).toBe(true)
58+
})
59+
60+
//#given mcb config with enabled: true and specific tools disabled
61+
//#when initializeMcbFromConfig is called
62+
//#then mcb is available but specific tools are disabled
63+
it("respects per-tool configuration", () => {
64+
const config: McbConfig = {
65+
enabled: true,
66+
tools: {
67+
memory: false,
68+
vcs: false,
69+
search: true, // explicit true
70+
// index implicit true
71+
},
72+
}
73+
initializeMcbFromConfig(config)
74+
75+
const status = getMcbAvailability()
76+
expect(status.available).toBe(true)
77+
expect(status.tools.memory).toBe(false)
78+
expect(status.tools.vcs).toBe(false)
79+
expect(status.tools.search).toBe(true)
80+
expect(status.tools.index).toBe(true) // default
81+
})
82+
83+
//#given mcb is initialized and locked
84+
//#when time passes (simulated)
85+
//#then the configuration is NOT overwritten by cache expiration logic
86+
it("locks configuration against cache expiration", () => {
87+
// 1. Initialize as disabled
88+
initializeMcbFromConfig({ enabled: false })
89+
90+
// 2. Verify disabled
91+
expect(getMcbAvailability().available).toBe(false)
92+
93+
// 3. Even if we manually reset the internal cache (simulating expiry logic inside availability.ts),
94+
// the lock should prevent re-enabling if we were able to modify availability.ts to expose it.
95+
// However, since we can't easily mock the internal state of availability.ts without
96+
// more complex mocking, we rely on the contract that initializeMcbFromConfig calls lockMcbAvailability.
97+
98+
// Instead, let's verify that calling getMcbAvailability multiple times returns the same result
99+
for (let i = 0; i < 5; i++) {
100+
expect(getMcbAvailability().available).toBe(false)
101+
}
102+
})
103+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { McbConfig } from "../../config/schema/mcb"
2+
import type { McbToolAvailability } from "./types"
3+
import { lockMcbAvailability, markMcbUnavailable, resetMcbAvailability } from "./availability"
4+
5+
export function initializeMcbFromConfig(mcbConfig?: McbConfig): void {
6+
resetMcbAvailability()
7+
8+
if (!mcbConfig?.enabled) {
9+
markMcbUnavailable()
10+
lockMcbAvailability()
11+
return
12+
}
13+
14+
if (mcbConfig.tools) {
15+
const toolKeys: (keyof McbToolAvailability)[] = ["search", "memory", "index", "validate", "vcs", "session"]
16+
for (const key of toolKeys) {
17+
if (mcbConfig.tools[key] === false) {
18+
markMcbUnavailable(key)
19+
}
20+
}
21+
}
22+
23+
lockMcbAvailability()
24+
}

src/features/mcb-integration/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { getMcbAvailability, markMcbUnavailable, resetMcbAvailability } from "./availability"
22
export { withMcbFallback } from "./graceful-wrapper"
33
export type { McbOperationResult } from "./graceful-wrapper"
4+
export { initializeMcbFromConfig } from "./config-gate"
45
export type {
56
McbSearchParams,
67
McbMemoryStoreParams,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { PluginInput } from "@opencode-ai/plugin"
2+
import { detectAmbiguity, extractPromptText } from "./patterns"
3+
import type { ChatMessageInput, ChatMessageOutput } from "./types"
4+
5+
function buildClarificationGuidance(reasons: string[]): string {
6+
const reasonText = reasons.length > 0 ? reasons.join(", ") : "insufficient context"
7+
return [
8+
"Clarification needed before implementation:",
9+
`Detected ambiguity signals: ${reasonText}.`,
10+
"Please include: target file/function, expected outcome, and measurable success criteria.",
11+
].join("\n")
12+
}
13+
14+
export function createAmbiguityDetectorHook(_ctx: PluginInput) {
15+
return {
16+
"chat.message": async (input: ChatMessageInput, output: ChatMessageOutput): Promise<void> => {
17+
if (!input.sessionID) return
18+
19+
const promptText = extractPromptText(output.parts)
20+
if (!promptText) return
21+
22+
const result = detectAmbiguity(promptText)
23+
if (!result.ambiguous) return
24+
25+
output.parts.push({
26+
type: "text",
27+
text: buildClarificationGuidance(result.reasons),
28+
})
29+
},
30+
}
31+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "bun:test"
2+
import { createAmbiguityDetectorHook } from "./index"
3+
4+
describe("ambiguity-detector", () => {
5+
const createOutput = (text: string) => ({
6+
message: {} as Record<string, unknown>,
7+
parts: [{ type: "text", text }],
8+
})
9+
10+
it("#given a vague user message without specific targets #when processed #then injects clarification guidance", async () => {
11+
//#given a vague user message without specific targets
12+
const hook = createAmbiguityDetectorHook({} as any)
13+
const output = createOutput("fix this quickly")
14+
15+
//#when the ambiguity detector processes it
16+
await hook["chat.message"]({ sessionID: "s1" }, output)
17+
18+
//#then it should inject clarification guidance
19+
expect(output.parts).toHaveLength(2)
20+
expect(output.parts[1].type).toBe("text")
21+
expect(output.parts[1].text).toContain("Clarification needed")
22+
})
23+
24+
it("#given a specific user message with file path #when processed #then does not inject guidance", async () => {
25+
//#given a specific user message with file paths
26+
const hook = createAmbiguityDetectorHook({} as any)
27+
const output = createOutput("Update src/plugin/chat-message.ts:79 to call new hook")
28+
29+
//#when the ambiguity detector processes it
30+
await hook["chat.message"]({ sessionID: "s1" }, output)
31+
32+
//#then it should NOT inject any guidance
33+
expect(output.parts).toHaveLength(1)
34+
expect(output.parts[0].text).toBe("Update src/plugin/chat-message.ts:79 to call new hook")
35+
})
36+
37+
it("#given message with no text parts #when processed #then does nothing", async () => {
38+
const hook = createAmbiguityDetectorHook({} as any)
39+
const output = {
40+
message: {} as Record<string, unknown>,
41+
parts: [{ type: "tool_use" as const }],
42+
}
43+
44+
await hook["chat.message"]({ sessionID: "s1" }, output)
45+
46+
expect(output.parts).toHaveLength(1)
47+
expect(output.parts[0].type).toBe("tool_use")
48+
})
49+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./types"
2+
export * from "./patterns"
3+
export { createAmbiguityDetectorHook } from "./hook"

0 commit comments

Comments
 (0)