claude-brain 0.24.2 → 0.25.1

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.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.24.2",
3
+ "version": "0.25.1",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -165,6 +165,7 @@ logLevel: warn
165
165
  const HOOK_FILES = [
166
166
  'brain-hook.ts',
167
167
  'context-hook.ts',
168
+ 'interceptor-hook.ts',
168
169
  'capture.ts',
169
170
  'queue.ts',
170
171
  'types.ts',
@@ -215,7 +216,8 @@ function installHooks() {
215
216
  hasOurHooks(settings.hooks.PostToolUse) &&
216
217
  hasOurHooks(settings.hooks.Stop) &&
217
218
  hasOurHooks(settings.hooks.UserPromptSubmit) &&
218
- hasOurHooks(settings.hooks.SessionStart)) {
219
+ hasOurHooks(settings.hooks.SessionStart) &&
220
+ hasOurHooks(settings.hooks.PreToolUse)) {
219
221
  log('Hooks already installed')
220
222
  return true
221
223
  }
@@ -223,8 +225,10 @@ function installHooks() {
223
225
  // Build hook command
224
226
  const brainScriptPath = join(HOME, 'hooks', 'brain-hook.ts')
225
227
  const contextScriptPath = join(HOME, 'hooks', 'context-hook.ts')
228
+ const interceptorScriptPath = join(HOME, 'hooks', 'interceptor-hook.ts')
229
+ const port = process.env.CLAUDE_BRAIN_PORT || process.env.PORT || '3000'
226
230
  function buildCmd(event, scriptPath) {
227
- return `bun "${scriptPath}" --event ${event} # ${HOOK_MARKER}`
231
+ return `bun "${scriptPath}" --event ${event} --port ${port} # ${HOOK_MARKER}`
228
232
  }
229
233
 
230
234
  if (!settings.hooks) settings.hooks = {}
@@ -265,6 +269,19 @@ function installHooks() {
265
269
  })
266
270
  }
267
271
 
272
+ // PreToolUse — code intelligence interceptor for Glob and Grep
273
+ if (!hasOurHooks(settings.hooks.PreToolUse)) {
274
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []
275
+ settings.hooks.PreToolUse.push({
276
+ matcher: 'Glob',
277
+ hooks: [{ type: 'command', command: buildCmd('PreToolUse', interceptorScriptPath) }],
278
+ })
279
+ settings.hooks.PreToolUse.push({
280
+ matcher: 'Grep',
281
+ hooks: [{ type: 'command', command: buildCmd('PreToolUse', interceptorScriptPath) }],
282
+ })
283
+ }
284
+
268
285
  // Write atomically
269
286
  if (!existsSync(CLAUDE_DIR)) {
270
287
  mkdirSync(CLAUDE_DIR, { recursive: true })
@@ -109,7 +109,7 @@ async function main(): Promise<void> {
109
109
  }).catch(() => null)
110
110
 
111
111
  // Phase 29: Detect file paths in the user's prompt and fetch linked memories
112
- const projectName = input.cwd ? input.cwd.split('/').filter(Boolean).pop() : ''
112
+ const projectName = input.cwd ? input.cwd.split(/[/\\]/).filter(Boolean).pop() : ''
113
113
  const filePaths = extractFilePathsFromPrompt(input.prompt)
114
114
  const fileMemoryPromises = filePaths.slice(0, 3).map(fp =>
115
115
  fetch(`${baseUrl}/api/memory/for-file?file=${encodeURIComponent(fp)}&project=${encodeURIComponent(projectName || '')}`, {
@@ -135,7 +135,7 @@ async function main(): Promise<void> {
135
135
  if (input.cwd) params.set('cwd', input.cwd)
136
136
 
137
137
  // Extract project name from cwd for code map
138
- const projectName = input.cwd ? input.cwd.split('/').filter(Boolean).pop() : undefined
138
+ const projectName = input.cwd ? input.cwd.split(/[/\\]/).filter(Boolean).pop() : undefined
139
139
 
140
140
  // Fetch brain context AND code map in parallel
141
141
  const [contextRes, codeMapRes] = await Promise.all([
@@ -70,7 +70,7 @@ function resolveProjectName(raw: string): string {
70
70
  let name = raw.trim()
71
71
  // Strip npm scoped prefix: @scope/name → name
72
72
  if (name.startsWith('@') && name.includes('/')) {
73
- name = name.split('/').pop() || name
73
+ name = name.split(/[/\\]/).pop() || name
74
74
  }
75
75
  return name || raw
76
76
  }
@@ -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
@@ -194,7 +230,10 @@ export function isHooksInstalled(): boolean {
194
230
  const hasUserPromptSubmit = Array.isArray(settings.hooks.UserPromptSubmit) &&
195
231
  settings.hooks.UserPromptSubmit.some((entry: any) => isOurHookEntry(entry))
196
232
 
197
- return hasPostToolUse || hasStop || hasUserPromptSubmit
233
+ const hasPreToolUse = Array.isArray(settings.hooks.PreToolUse) &&
234
+ settings.hooks.PreToolUse.some((entry: any) => isOurHookEntry(entry))
235
+
236
+ return hasPostToolUse || hasStop || hasUserPromptSubmit || hasPreToolUse
198
237
  }
199
238
 
200
239
  /** Check if a hook entry belongs to us (by marker in command) */
@@ -209,6 +248,7 @@ function isOurHookEntry(entry: any): boolean {
209
248
  const HOOK_FILES = [
210
249
  'brain-hook.ts',
211
250
  'context-hook.ts',
251
+ 'interceptor-hook.ts',
212
252
  'capture.ts',
213
253
  'queue.ts',
214
254
  'types.ts',
@@ -0,0 +1,201 @@
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
+ // Split by both / and \ for cross-platform support (Windows uses backslashes)
137
+ const segments = cwd.split(/[/\\]/).filter(Boolean)
138
+ return segments[segments.length - 1] || null
139
+ }
140
+
141
+ function formatResults(
142
+ results: SymbolResult[],
143
+ toolName: string,
144
+ searchTerm: string
145
+ ): string {
146
+ const lines: string[] = [
147
+ `Code index found ${results.length} match${results.length === 1 ? '' : 'es'} for "${searchTerm}" (skip ${toolName}, use Read directly):`,
148
+ '',
149
+ ]
150
+
151
+ for (const r of results.slice(0, 8)) {
152
+ const sig = r.signature ? ` ${r.signature}` : ''
153
+ const lineRange = r.lineEnd
154
+ ? `${r.lineStart}-${r.lineEnd}`
155
+ : `${r.lineStart}`
156
+ lines.push(
157
+ ` ${r.symbol} (${r.type})${sig} → ${r.filePath}:${lineRange}`
158
+ )
159
+ }
160
+
161
+ if (results.length > 8) {
162
+ lines.push(` ... and ${results.length - 8} more`)
163
+ }
164
+
165
+ // Add actionable tip
166
+ lines.push('')
167
+ const top = results[0]!
168
+ const offset = Math.max(1, top.lineStart - 5)
169
+ const limit = ((top.lineEnd || top.lineStart) + 20) - top.lineStart + 10
170
+ lines.push(
171
+ `Tip: Read("${top.filePath}", offset=${offset}, limit=${limit}) for the ${top.type} body.`
172
+ )
173
+
174
+ return lines.join('\n')
175
+ }
176
+
177
+ /** Read all of stdin as a string */
178
+ function readStdin(): Promise<string> {
179
+ return new Promise((resolve, reject) => {
180
+ const chunks: Buffer[] = []
181
+ const stdin = process.stdin
182
+
183
+ stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
184
+ stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
185
+ stdin.on('error', reject)
186
+
187
+ // Timeout after 2 seconds
188
+ setTimeout(() => {
189
+ stdin.destroy()
190
+ resolve(Buffer.concat(chunks).toString('utf-8'))
191
+ }, 2000)
192
+ })
193
+ }
194
+
195
+ // Execute when run directly
196
+ const isDirectRun = process.argv[1]?.includes('interceptor-hook')
197
+ if (isDirectRun) {
198
+ main().catch(() => process.exit(0))
199
+ }
200
+
201
+ export { main }
@@ -305,7 +305,7 @@ export class PassiveClassifier {
305
305
  }
306
306
 
307
307
  // Check for Dockerfile without extension
308
- const basename = filePath.split('/').pop()?.toLowerCase() || ''
308
+ const basename = filePath.split(/[/\\]/).pop()?.toLowerCase() || ''
309
309
  if (basename === 'dockerfile' || basename.startsWith('dockerfile.')) {
310
310
  techs.push('docker')
311
311
  }