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.
|
|
1
|
+
0.25.1
|
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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([
|
package/src/hooks/git-capture.ts
CHANGED
|
@@ -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(
|
|
73
|
+
name = name.split(/[/\\]/).pop() || name
|
|
74
74
|
}
|
|
75
75
|
return name || raw
|
|
76
76
|
}
|
package/src/hooks/installer.ts
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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(
|
|
308
|
+
const basename = filePath.split(/[/\\]/).pop()?.toLowerCase() || ''
|
|
309
309
|
if (basename === 'dockerfile' || basename.startsWith('dockerfile.')) {
|
|
310
310
|
techs.push('docker')
|
|
311
311
|
}
|