claude-brain 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.2
1
+ 0.25.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,6 +22,11 @@ export function getContextHookScriptPath(): string {
22
22
  return join(getClaudeBrainHome(), 'hooks', 'context-hook.ts')
23
23
  }
24
24
 
25
+ /** Get the path where the interceptor-hook script should be installed */
26
+ export function getInterceptorHookScriptPath(): string {
27
+ return join(getClaudeBrainHome(), 'hooks', 'interceptor-hook.ts')
28
+ }
29
+
25
30
  /** Read Claude Code settings.json, creating if needed */
26
31
  function readSettings(): Record<string, any> {
27
32
  if (!existsSync(CLAUDE_SETTINGS_PATH)) return {}
@@ -47,8 +52,12 @@ function writeSettings(settings: Record<string, any>): void {
47
52
  /** Build the hook command string, embedding the port so hooks work regardless of env.
48
53
  * BUG-006: settings.json syncs across platforms. We use --port arg instead of
49
54
  * platform-specific env var syntax (VAR=val on Unix, set VAR=val on Windows). */
50
- function buildHookCommand(event: string, script: 'brain-hook' | 'context-hook' = 'brain-hook'): string {
51
- const scriptPath = script === 'context-hook' ? getContextHookScriptPath() : getHookScriptPath()
55
+ function buildHookCommand(event: string, script: 'brain-hook' | 'context-hook' | 'interceptor-hook' = 'brain-hook'): string {
56
+ const scriptPath = script === 'context-hook'
57
+ ? getContextHookScriptPath()
58
+ : script === 'interceptor-hook'
59
+ ? getInterceptorHookScriptPath()
60
+ : getHookScriptPath()
52
61
  const port = process.env.PORT || process.env.CLAUDE_BRAIN_PORT || '3000'
53
62
 
54
63
  return `bun "${scriptPath}" --event ${event} --port ${port} # ${HOOK_MARKER}`
@@ -108,6 +117,23 @@ export function installHooks(): { installed: boolean; message: string } {
108
117
  }],
109
118
  })
110
119
 
120
+ // PreToolUse interceptor — blocks Glob/Grep when code index has results
121
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []
122
+ settings.hooks.PreToolUse.push({
123
+ matcher: 'Glob',
124
+ hooks: [{
125
+ type: 'command',
126
+ command: buildHookCommand('PreToolUse', 'interceptor-hook'),
127
+ }],
128
+ })
129
+ settings.hooks.PreToolUse.push({
130
+ matcher: 'Grep',
131
+ hooks: [{
132
+ type: 'command',
133
+ command: buildHookCommand('PreToolUse', 'interceptor-hook'),
134
+ }],
135
+ })
136
+
111
137
  writeSettings(settings)
112
138
 
113
139
  // Copy hook script to install location
@@ -167,6 +193,16 @@ export function uninstallHooks(): { uninstalled: boolean; message: string } {
167
193
  }
168
194
  }
169
195
 
196
+ // Remove our entries from PreToolUse
197
+ if (Array.isArray(settings.hooks.PreToolUse)) {
198
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
199
+ (entry: any) => !isOurHookEntry(entry)
200
+ )
201
+ if (settings.hooks.PreToolUse.length === 0) {
202
+ delete settings.hooks.PreToolUse
203
+ }
204
+ }
205
+
170
206
  // Clean up empty hooks object
171
207
  if (Object.keys(settings.hooks).length === 0) {
172
208
  delete settings.hooks
@@ -209,6 +245,7 @@ function isOurHookEntry(entry: any): boolean {
209
245
  const HOOK_FILES = [
210
246
  'brain-hook.ts',
211
247
  'context-hook.ts',
248
+ 'interceptor-hook.ts',
212
249
  'capture.ts',
213
250
  'queue.ts',
214
251
  'types.ts',
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Code Intelligence Interceptor — PreToolUse Hook
4
+ *
5
+ * Intercepts Glob and Grep calls, queries the code index,
6
+ * and blocks the tool when confident results exist.
7
+ *
8
+ * CRITICAL CONSTRAINTS:
9
+ * - Must complete in <3s (PreToolUse timeout)
10
+ * - Stderr = message shown to Claude when blocking
11
+ * - Exit 0 = allow tool, exit non-zero = block tool
12
+ * - NEVER write to stdout (corrupts JSON-RPC)
13
+ */
14
+
15
+ interface HookStdin {
16
+ session_id: string
17
+ tool_name: string
18
+ tool_input: Record<string, any>
19
+ cwd?: string
20
+ }
21
+
22
+ interface SymbolResult {
23
+ symbol: string
24
+ type: string
25
+ filePath: string
26
+ lineStart: number
27
+ lineEnd?: number
28
+ signature?: string
29
+ confidence: number
30
+ }
31
+
32
+ async function main(): Promise<void> {
33
+ const rawInput = await readStdin()
34
+ if (!rawInput.trim()) { process.exit(0); return }
35
+
36
+ let input: HookStdin
37
+ try { input = JSON.parse(rawInput) }
38
+ catch { process.exit(0); return }
39
+
40
+ // Only intercept Glob and Grep
41
+ if (!['Glob', 'Grep'].includes(input.tool_name)) {
42
+ process.exit(0)
43
+ return
44
+ }
45
+
46
+ // Extract search intent from tool input
47
+ const searchTerm = extractSearchIntent(input)
48
+ if (!searchTerm) { process.exit(0); return }
49
+
50
+ // Detect project from cwd
51
+ const project = detectProject(input.cwd)
52
+ if (!project) { process.exit(0); return }
53
+
54
+ // Read port from --port arg or env
55
+ const portIdx = process.argv.indexOf('--port')
56
+ const portArg = portIdx >= 0 ? process.argv[portIdx + 1] : undefined
57
+ const port = parseInt(portArg || process.env.CLAUDE_BRAIN_PORT || '3000', 10)
58
+
59
+ try {
60
+ const params = new URLSearchParams({
61
+ query: searchTerm,
62
+ project,
63
+ limit: '10',
64
+ })
65
+
66
+ const res = await fetch(
67
+ `http://localhost:${port}/api/code/search?${params}`,
68
+ { signal: AbortSignal.timeout(2000) }
69
+ )
70
+
71
+ if (!res.ok) { process.exit(0); return }
72
+
73
+ const data = await res.json() as { data?: SymbolResult[] }
74
+ const results: SymbolResult[] = Array.isArray(data.data) ? data.data : (data.data as any) || []
75
+
76
+ // Only block if we have confident results
77
+ if (results.length > 0 && results[0].confidence > 0.7) {
78
+ const message = formatResults(results, input.tool_name, searchTerm)
79
+ process.stderr.write(message)
80
+ process.exit(2) // non-zero = BLOCK
81
+ } else {
82
+ process.exit(0) // let original tool run
83
+ }
84
+ } catch {
85
+ // Any error = let original tool run (safe fallback)
86
+ process.exit(0)
87
+ }
88
+ }
89
+
90
+ function extractSearchIntent(input: HookStdin): string | null {
91
+ const toolInput = input.tool_input
92
+
93
+ if (input.tool_name === 'Glob') {
94
+ const pattern = toolInput.pattern || ''
95
+ if (isGenericGlob(pattern)) return null
96
+ return extractGlobKeyword(pattern)
97
+ }
98
+
99
+ if (input.tool_name === 'Grep') {
100
+ const pattern = toolInput.pattern || ''
101
+ if (isComplexRegex(pattern)) return null
102
+ return pattern
103
+ }
104
+
105
+ return null
106
+ }
107
+
108
+ /** Returns true for patterns like "**\/*.ts", "**\/*.js" that match too broadly */
109
+ function isGenericGlob(pattern: string): boolean {
110
+ return /^\*\*\/\*\.\w+$/.test(pattern)
111
+ }
112
+
113
+ /** Extract the meaningful keyword from a glob pattern */
114
+ function extractGlobKeyword(pattern: string): string | null {
115
+ const cleaned = pattern
116
+ .replace(/\*\*/g, '')
117
+ .replace(/\*/g, '')
118
+ .replace(/\//g, ' ')
119
+ .replace(/\.\w+$/g, '') // remove extension
120
+ .trim()
121
+
122
+ const words = cleaned.split(/\s+/).filter(w => w.length >= 3)
123
+ return words.length > 0 ? words.join(' ') : null
124
+ }
125
+
126
+ /** Returns true for patterns that are complex regex, not simple symbol names */
127
+ function isComplexRegex(pattern: string): boolean {
128
+ // Simple word-like patterns are fine to intercept
129
+ if (/^\w+$/.test(pattern)) return false
130
+ // Patterns with regex meta-chars are complex
131
+ return /[\\()[\]{}|^$+?]/.test(pattern)
132
+ }
133
+
134
+ function detectProject(cwd?: string): string | null {
135
+ if (!cwd) return null
136
+ const segments = cwd.split('/').filter(Boolean)
137
+ return segments[segments.length - 1] || null
138
+ }
139
+
140
+ function formatResults(
141
+ results: SymbolResult[],
142
+ toolName: string,
143
+ searchTerm: string
144
+ ): string {
145
+ const lines: string[] = [
146
+ `Code index found ${results.length} match${results.length === 1 ? '' : 'es'} for "${searchTerm}" (skip ${toolName}, use Read directly):`,
147
+ '',
148
+ ]
149
+
150
+ for (const r of results.slice(0, 8)) {
151
+ const sig = r.signature ? ` ${r.signature}` : ''
152
+ const lineRange = r.lineEnd
153
+ ? `${r.lineStart}-${r.lineEnd}`
154
+ : `${r.lineStart}`
155
+ lines.push(
156
+ ` ${r.symbol} (${r.type})${sig} → ${r.filePath}:${lineRange}`
157
+ )
158
+ }
159
+
160
+ if (results.length > 8) {
161
+ lines.push(` ... and ${results.length - 8} more`)
162
+ }
163
+
164
+ // Add actionable tip
165
+ lines.push('')
166
+ const top = results[0]!
167
+ const offset = Math.max(1, top.lineStart - 5)
168
+ const limit = ((top.lineEnd || top.lineStart) + 20) - top.lineStart + 10
169
+ lines.push(
170
+ `Tip: Read("${top.filePath}", offset=${offset}, limit=${limit}) for the ${top.type} body.`
171
+ )
172
+
173
+ return lines.join('\n')
174
+ }
175
+
176
+ /** Read all of stdin as a string */
177
+ function readStdin(): Promise<string> {
178
+ return new Promise((resolve, reject) => {
179
+ const chunks: Buffer[] = []
180
+ const stdin = process.stdin
181
+
182
+ stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
183
+ stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
184
+ stdin.on('error', reject)
185
+
186
+ // Timeout after 2 seconds
187
+ setTimeout(() => {
188
+ stdin.destroy()
189
+ resolve(Buffer.concat(chunks).toString('utf-8'))
190
+ }, 2000)
191
+ })
192
+ }
193
+
194
+ // Execute when run directly
195
+ const isDirectRun = process.argv[1]?.includes('interceptor-hook')
196
+ if (isDirectRun) {
197
+ main().catch(() => process.exit(0))
198
+ }
199
+
200
+ export { main }