claude-brain 0.24.1 → 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.1
1
+ 0.25.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.24.1",
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",
@@ -342,7 +342,7 @@ function buildAutoStartSnippet(profilePath) {
342
342
  'if (Get-Command claude-brain -ErrorAction SilentlyContinue) {',
343
343
  ' $listening = Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue',
344
344
  ' if (-not $listening) {',
345
- ' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve" -WindowStyle Hidden',
345
+ ' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve","--http-only" -WindowStyle Hidden',
346
346
  ' }',
347
347
  '}',
348
348
  AUTO_END_MARKER,
@@ -350,9 +350,9 @@ function buildAutoStartSnippet(profilePath) {
350
350
  }
351
351
  return [
352
352
  AUTO_START_MARKER,
353
- '# Auto-start claude-brain server if not already running',
353
+ '# Auto-start claude-brain HTTP server if not already running',
354
354
  '(command -v claude-brain >/dev/null 2>&1 && ! lsof -ti :3000 >/dev/null 2>&1) && {',
355
- ' nohup claude-brain serve >/dev/null 2>&1 &',
355
+ ' nohup claude-brain serve --http-only >/dev/null 2>&1 &',
356
356
  ' disown 2>/dev/null',
357
357
  '}',
358
358
  AUTO_END_MARKER,
@@ -32,9 +32,9 @@ export interface AutoStartResult {
32
32
  function buildUnixSnippet(port: number): string {
33
33
  return [
34
34
  START_MARKER,
35
- '# Auto-start claude-brain server if not already running',
35
+ '# Auto-start claude-brain HTTP server if not already running',
36
36
  `(command -v claude-brain >/dev/null 2>&1 && ! lsof -ti :${port} >/dev/null 2>&1) && {`,
37
- ' nohup claude-brain serve >/dev/null 2>&1 &',
37
+ ' nohup claude-brain serve --http-only >/dev/null 2>&1 &',
38
38
  ' disown 2>/dev/null',
39
39
  '}',
40
40
  END_MARKER,
@@ -48,9 +48,9 @@ function buildUnixSnippet(port: number): string {
48
48
  function buildFishSnippet(port: number): string {
49
49
  return [
50
50
  START_MARKER,
51
- '# Auto-start claude-brain server if not already running',
51
+ '# Auto-start claude-brain HTTP server if not already running',
52
52
  `if command -v claude-brain >/dev/null 2>&1; and not lsof -ti :${port} >/dev/null 2>&1`,
53
- ' nohup claude-brain serve >/dev/null 2>&1 &',
53
+ ' nohup claude-brain serve --http-only >/dev/null 2>&1 &',
54
54
  'end',
55
55
  END_MARKER,
56
56
  ].join('\n')
@@ -66,7 +66,7 @@ function buildPowerShellSnippet(port: number): string {
66
66
  ` $port = ${port}`,
67
67
  ' $listening = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue',
68
68
  ' if (-not $listening) {',
69
- ' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve" -WindowStyle Hidden',
69
+ ' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve","--http-only" -WindowStyle Hidden',
70
70
  ' }',
71
71
  '}',
72
72
  END_MARKER,
@@ -18,17 +18,22 @@ export async function runServe() {
18
18
  // Auto-initialize home directory on first run
19
19
  ensureHomeDirectory()
20
20
 
21
- // Singleton check: prevent multiple server instances
21
+ // --http-only: run HTTP server + services but no MCP stdio transport.
22
+ // Used by auto-start background process (stdin is /dev/null, no Claude Code attached).
23
+ const httpOnly = process.argv.includes('--http-only')
24
+
25
+ // Determine instance role:
26
+ // - httpOnly → always primary (background HTTP daemon)
27
+ // - otherwise → secondary if primary is already running (Claude Code MCP session)
22
28
  const pidManager = new ServerPidManager()
23
29
  const existingPid = pidManager.getRunningPid()
24
- if (existingPid) {
25
- console.error(`[claude-brain] Server already running (PID: ${existingPid}). Exiting.`)
26
- process.exit(0)
27
- }
30
+ const isSecondaryInstance = !httpOnly && !!existingPid
28
31
 
29
- // Write PID file and register cleanup handlers
30
- pidManager.writePidFile()
31
- pidManager.registerCleanupHandlers()
32
+ if (!isSecondaryInstance) {
33
+ // Primary instance: write PID file and register cleanup
34
+ pidManager.writePidFile()
35
+ pidManager.registerCleanupHandlers()
36
+ }
32
37
 
33
38
  // Auto-install Claude Code hooks (idempotent, non-fatal)
34
39
  try {
@@ -162,141 +167,147 @@ export async function runServe() {
162
167
  await shutdownServices()
163
168
  })
164
169
 
165
- // Clean up PID file during graceful shutdown
166
- cleanup.register(async () => {
167
- pidManager.cleanup()
168
- })
170
+ // ── Primary instance only: HTTP server + background services ──
171
+ // Secondary instances (when auto-start already running) only start MCP stdio
172
+ if (!isSecondaryInstance) {
173
+ // Clean up PID file during graceful shutdown
174
+ cleanup.register(async () => {
175
+ pidManager.cleanup()
176
+ })
169
177
 
170
- // Start HTTP API server alongside MCP server
171
- const { HttpApiServer } = await import('@/server/http-api')
172
- const httpServer = new HttpApiServer(config, logger)
178
+ // Start HTTP API server alongside MCP server
179
+ const { HttpApiServer } = await import('@/server/http-api')
180
+ const httpServer = new HttpApiServer(config, logger)
173
181
 
174
- cleanup.register(async () => {
175
- await httpServer.stop()
176
- })
182
+ cleanup.register(async () => {
183
+ await httpServer.stop()
184
+ })
177
185
 
178
- // Phase 21: Use session tracker from services (promoted from local creation)
179
- {
180
- const { getSessionTracker } = await import('@/server/services')
181
- const sessionTracker = getSessionTracker()
182
- if (sessionTracker) {
183
- httpServer.setSessionTracker(sessionTracker)
184
- mainLogger.info('Session tracker wired to HTTP server')
186
+ // Phase 21: Use session tracker from services (promoted from local creation)
187
+ {
188
+ const { getSessionTracker } = await import('@/server/services')
189
+ const sessionTracker = getSessionTracker()
190
+ if (sessionTracker) {
191
+ httpServer.setSessionTracker(sessionTracker)
192
+ mainLogger.info('Session tracker wired to HTTP server')
193
+ }
185
194
  }
186
- }
187
195
 
188
- // Phase 28: Wire code intelligence to HTTP server
189
- {
190
- const { getCodeIndexer, getCodeQuery } = await import('@/server/services')
191
- const codeIndexer = getCodeIndexer()
192
- const codeQuery = getCodeQuery()
193
- if (codeIndexer && codeQuery) {
194
- httpServer.setCodeIntelligence(codeIndexer, codeQuery)
195
- mainLogger.info('Code intelligence wired to HTTP server')
196
+ // Phase 28: Wire code intelligence to HTTP server
197
+ {
198
+ const { getCodeIndexer, getCodeQuery } = await import('@/server/services')
199
+ const codeIndexer = getCodeIndexer()
200
+ const codeQuery = getCodeQuery()
201
+ if (codeIndexer && codeQuery) {
202
+ httpServer.setCodeIntelligence(codeIndexer, codeQuery)
203
+ mainLogger.info('Code intelligence wired to HTTP server')
204
+ }
196
205
  }
197
- }
198
206
 
199
- // Phase 29: Wire code linker to HTTP server
200
- {
201
- const { getCodeLinker } = await import('@/server/services')
202
- const codeLinker = getCodeLinker()
203
- if (codeLinker) {
204
- httpServer.setCodeLinker(codeLinker)
205
- mainLogger.info('Code linker wired to HTTP server')
207
+ // Phase 29: Wire code linker to HTTP server
208
+ {
209
+ const { getCodeLinker } = await import('@/server/services')
210
+ const codeLinker = getCodeLinker()
211
+ if (codeLinker) {
212
+ httpServer.setCodeLinker(codeLinker)
213
+ mainLogger.info('Code linker wired to HTTP server')
214
+ }
206
215
  }
207
- }
208
216
 
209
- // Phase 30: Activity log pruning on startup + periodic cleanup
210
- {
211
- const memory = getMemoryService()
212
- if (memory?.isInitialized()) {
213
- try {
214
- const { startPeriodicPruning } = await import('@/memory/pruning')
215
- const db = memory.database.getDb()
216
- const stopPruning = startPeriodicPruning(db, logger, 30)
217
- cleanup.register(async () => {
218
- stopPruning()
219
- mainLogger.info('Activity log pruning stopped')
220
- })
221
- mainLogger.info('Activity log pruning initialized')
222
- } catch (error) {
223
- mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
217
+ // Phase 30: Activity log pruning on startup + periodic cleanup
218
+ {
219
+ const memory = getMemoryService()
220
+ if (memory?.isInitialized()) {
221
+ try {
222
+ const { startPeriodicPruning } = await import('@/memory/pruning')
223
+ const db = memory.database.getDb()
224
+ const stopPruning = startPeriodicPruning(db, logger, 30)
225
+ cleanup.register(async () => {
226
+ stopPruning()
227
+ mainLogger.info('Activity log pruning stopped')
228
+ })
229
+ mainLogger.info('Activity log pruning initialized')
230
+ } catch (error) {
231
+ mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
232
+ }
224
233
  }
225
234
  }
226
- }
227
235
 
228
- // Phase 30: Optional LLM compression
229
- if (config.compression?.enabled) {
230
- try {
231
- const { ObservationCompressor } = await import('@/memory/compression')
232
- const { getBrainRouter } = await import('@/routing/router')
233
- const compressor = new ObservationCompressor(config.compression, logger)
234
- const router = getBrainRouter(logger)
235
- router.setCompressor(compressor)
236
- mainLogger.info({ provider: config.compression.provider, model: config.compression.model }, 'LLM compression enabled')
237
- } catch (error) {
238
- mainLogger.warn({ error }, 'Failed to initialize LLM compression')
236
+ // Phase 30: Optional LLM compression
237
+ if (config.compression?.enabled) {
238
+ try {
239
+ const { ObservationCompressor } = await import('@/memory/compression')
240
+ const { getBrainRouter } = await import('@/routing/router')
241
+ const compressor = new ObservationCompressor(config.compression, logger)
242
+ const router = getBrainRouter(logger)
243
+ router.setCompressor(compressor)
244
+ mainLogger.info({ provider: config.compression.provider, model: config.compression.model }, 'LLM compression enabled')
245
+ } catch (error) {
246
+ mainLogger.warn({ error }, 'Failed to initialize LLM compression')
247
+ }
239
248
  }
240
- }
241
-
242
- // Phase 31: Auto-update checker
243
- let autoUpdater: InstanceType<typeof import('@/server/auto-updater').AutoUpdater> | null = null
244
- if (config.autoUpdate?.enabled !== false) {
245
- try {
246
- const { AutoUpdater } = await import('@/server/auto-updater')
247
- autoUpdater = new AutoUpdater(
248
- {
249
- enabled: config.autoUpdate?.enabled ?? true,
250
- checkIntervalHours: config.autoUpdate?.checkIntervalHours ?? 24,
251
- autoRestart: config.autoUpdate?.autoRestart ?? true,
252
- },
253
- mainLogger
254
- )
255
-
256
- // Check for updates on startup (non-blocking)
257
- autoUpdater.check().then(result => {
258
- if (result.updateAvailable && result.latestVersion) {
259
- mainLogger.info(
260
- { current: result.currentVersion, latest: result.latestVersion },
261
- 'Update available! Run: claude-brain update'
262
- )
263
- }
264
- }).catch(() => {})
265
249
 
266
- // Schedule periodic checks
267
- autoUpdater.schedulePeriodicCheck()
250
+ // Phase 31: Auto-update checker
251
+ let autoUpdater: InstanceType<typeof import('@/server/auto-updater').AutoUpdater> | null = null
252
+ if (config.autoUpdate?.enabled !== false) {
253
+ try {
254
+ const { AutoUpdater } = await import('@/server/auto-updater')
255
+ autoUpdater = new AutoUpdater(
256
+ {
257
+ enabled: config.autoUpdate?.enabled ?? true,
258
+ checkIntervalHours: config.autoUpdate?.checkIntervalHours ?? 24,
259
+ autoRestart: config.autoUpdate?.autoRestart ?? true,
260
+ },
261
+ mainLogger
262
+ )
263
+
264
+ // Check for updates on startup (non-blocking)
265
+ autoUpdater.check().then(result => {
266
+ if (result.updateAvailable && result.latestVersion) {
267
+ mainLogger.info(
268
+ { current: result.currentVersion, latest: result.latestVersion },
269
+ 'Update available! Run: claude-brain update'
270
+ )
271
+ }
272
+ }).catch(() => {})
273
+
274
+ // Schedule periodic checks
275
+ autoUpdater.schedulePeriodicCheck()
268
276
 
269
- cleanup.register(async () => {
270
- autoUpdater?.stopPeriodicCheck()
271
- mainLogger.info('Auto-updater stopped')
272
- })
277
+ cleanup.register(async () => {
278
+ autoUpdater?.stopPeriodicCheck()
279
+ mainLogger.info('Auto-updater stopped')
280
+ })
273
281
 
274
- mainLogger.info('Auto-updater initialized')
275
- } catch (error) {
276
- mainLogger.warn({ error }, 'Failed to initialize auto-updater')
282
+ mainLogger.info('Auto-updater initialized')
283
+ } catch (error) {
284
+ mainLogger.warn({ error }, 'Failed to initialize auto-updater')
285
+ }
277
286
  }
278
- }
279
-
280
- // Start HTTP server after MCP server is ready
281
- setTimeout(async () => {
282
- try {
283
- await httpServer.start()
284
- mainLogger.info({ port: config.port }, 'HTTP API server started')
285
287
 
286
- // Drain hook queue after HTTP server is ready
288
+ // Start HTTP server after MCP server is ready
289
+ setTimeout(async () => {
287
290
  try {
288
- const { drainQueue } = await import('@/hooks/queue')
289
- const drained = await drainQueue(config.port)
290
- if (drained > 0) {
291
- mainLogger.info({ drained }, 'Drained hook queue')
291
+ await httpServer.start()
292
+ mainLogger.info({ port: config.port }, 'HTTP API server started')
293
+
294
+ // Drain hook queue after HTTP server is ready
295
+ try {
296
+ const { drainQueue } = await import('@/hooks/queue')
297
+ const drained = await drainQueue(config.port)
298
+ if (drained > 0) {
299
+ mainLogger.info({ drained }, 'Drained hook queue')
300
+ }
301
+ } catch (error) {
302
+ mainLogger.debug({ error }, 'No hook queue to drain')
292
303
  }
293
304
  } catch (error) {
294
- mainLogger.debug({ error }, 'No hook queue to drain')
305
+ mainLogger.error({ error }, 'Failed to start HTTP API server')
295
306
  }
296
- } catch (error) {
297
- mainLogger.error({ error }, 'Failed to start HTTP API server')
298
- }
299
- }, 2000)
307
+ }, 2000)
308
+ } else {
309
+ mainLogger.info({ existingPid }, 'Secondary instance — HTTP server already running, starting MCP stdio only')
310
+ }
300
311
 
301
312
  const shutdown = async (signal: string) => {
302
313
  mainLogger.info({ signal }, 'Shutting down...')
@@ -307,7 +318,14 @@ export async function runServe() {
307
318
  process.on('SIGTERM', () => shutdown('SIGTERM'))
308
319
  process.on('SIGINT', () => shutdown('SIGINT'))
309
320
 
310
- await mcpServer.start()
311
-
312
- mainLogger.info('Claude Brain MCP server ready and listening for connections')
321
+ if (httpOnly) {
322
+ // HTTP-only mode: no MCP stdio. Keep process alive for HTTP server.
323
+ mainLogger.info('HTTP-only mode MCP stdio disabled (background daemon)')
324
+ // Keep the event loop alive with a long interval
325
+ const keepAlive = setInterval(() => {}, 60_000 * 60)
326
+ cleanup.register(async () => { clearInterval(keepAlive) })
327
+ } else {
328
+ await mcpServer.start()
329
+ mainLogger.info('Claude Brain MCP server ready and listening for connections')
330
+ }
313
331
  }
@@ -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 }