CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
nl-search.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 77cf834 | 1 | /** |
| 2 | * Natural Language Code Search — Claude-powered intent search. | |
| 3 | * | |
| 4 | * Unlike embedding-based semantic search (which requires a pre-built vector | |
| 5 | * index), NL search uses Claude as the reasoner over actual file content. | |
| 6 | * A developer types a natural language question like: | |
| 7 | * "find all places where we validate user email but don't check MX records" | |
| 8 | * "where do we write to the database without a transaction?" | |
| 9 | * and Claude finds the matching code. | |
| 10 | * | |
| 11 | * Algorithm: | |
| 12 | * 1. Quick Claude call to extract grep-friendly keywords from the query. | |
| 13 | * 2. Run `git grep -l <keyword>` for each keyword; union matching files. | |
| 14 | * Cap at 40 files. Fall back to code_chunks table if grep returns 0. | |
| 15 | * 3. Read each candidate file via git show (HEAD:<path>), capped at 6KB each. | |
| 16 | * Build combined context, capped at 80KB total. | |
| 17 | * 4. Single Claude reasoning pass: find all places matching the query, | |
| 18 | * return JSON with filePath, lineStart, lineEnd, snippet, explanation, | |
| 19 | * confidence. | |
| 20 | * 5. Cache results in-memory per `${repoId}:${query}` for 15 minutes. | |
| 21 | */ | |
| 22 | ||
| 23 | import { getAnthropic, MODEL_SONNET, isAiAvailable, parseJsonResponse } from "./ai-client"; | |
| 24 | import { getRepoPath } from "../git/repository"; | |
| 25 | import { db } from "../db"; | |
| 26 | import { codeChunks } from "../db/schema"; | |
| 27 | import { eq } from "drizzle-orm"; | |
| 28 | ||
| 29 | // --------------------------------------------------------------------------- | |
| 30 | // Public types | |
| 31 | // --------------------------------------------------------------------------- | |
| 32 | ||
| 33 | export interface NlSearchResult { | |
| 34 | filePath: string; | |
| 35 | lineStart: number; | |
| 36 | lineEnd: number; | |
| 37 | snippet: string; // the relevant lines | |
| 38 | explanation: string; // why this matches the query | |
| 39 | confidence: "high" | "medium" | "low"; | |
| 40 | } | |
| 41 | ||
| 42 | export interface NlSearchResponse { | |
| 43 | query: string; | |
| 44 | results: NlSearchResult[]; | |
| 45 | totalFilesScanned: number; | |
| 46 | searchedAt: Date; | |
| 47 | cached: boolean; | |
| 48 | } | |
| 49 | ||
| 50 | // --------------------------------------------------------------------------- | |
| 51 | // In-memory cache (15-minute TTL) | |
| 52 | // --------------------------------------------------------------------------- | |
| 53 | ||
| 54 | interface CacheEntry { | |
| 55 | response: NlSearchResponse; | |
| 56 | expiresAt: number; | |
| 57 | } | |
| 58 | ||
| 59 | const nlCache = new Map<string, CacheEntry>(); | |
| 60 | const NL_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes | |
| 61 | ||
| 62 | function cacheGet(key: string): NlSearchResponse | null { | |
| 63 | const entry = nlCache.get(key); | |
| 64 | if (!entry) return null; | |
| 65 | if (Date.now() > entry.expiresAt) { | |
| 66 | nlCache.delete(key); | |
| 67 | return null; | |
| 68 | } | |
| 69 | return entry.response; | |
| 70 | } | |
| 71 | ||
| 72 | function cacheSet(key: string, response: NlSearchResponse): void { | |
| 73 | // Evict expired entries to keep memory bounded. | |
| 74 | if (nlCache.size > 200) { | |
| 75 | const now = Date.now(); | |
| 76 | for (const [k, v] of nlCache) { | |
| 77 | if (v.expiresAt < now) nlCache.delete(k); | |
| 78 | } | |
| 79 | } | |
| 80 | nlCache.set(key, { response, expiresAt: Date.now() + NL_CACHE_TTL_MS }); | |
| 81 | } | |
| 82 | ||
| 83 | // --------------------------------------------------------------------------- | |
| 84 | // Skip-list: skip binary/build/lock files | |
| 85 | // --------------------------------------------------------------------------- | |
| 86 | ||
| 87 | const SKIP_DIRS = new Set([ | |
| 88 | "node_modules", | |
| 89 | ".git", | |
| 90 | "dist", | |
| 91 | "build", | |
| 92 | "vendor", | |
| 93 | ".next", | |
| 94 | ".turbo", | |
| 95 | "target", | |
| 96 | "__pycache__", | |
| 97 | ]); | |
| 98 | ||
| 99 | const SKIP_EXTS = new Set([ | |
| 100 | ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", | |
| 101 | ".woff", ".woff2", ".ttf", ".eot", | |
| 102 | ".pdf", ".zip", ".gz", ".tar", | |
| 103 | ".lockb", ".lock", | |
| 104 | ".min.js", ".min.css", | |
| 105 | ]); | |
| 106 | ||
| 107 | function shouldSkipPath(filePath: string): boolean { | |
| 108 | const parts = filePath.split("/"); | |
| 109 | for (const part of parts.slice(0, -1)) { | |
| 110 | if (SKIP_DIRS.has(part.toLowerCase())) return true; | |
| 111 | } | |
| 112 | const basename = parts[parts.length - 1].toLowerCase(); | |
| 113 | for (const ext of SKIP_EXTS) { | |
| 114 | if (basename.endsWith(ext)) return true; | |
| 115 | } | |
| 116 | // Lock files by exact name | |
| 117 | const lockFiles = new Set([ | |
| 118 | "package-lock.json", "yarn.lock", "pnpm-lock.yaml", | |
| 119 | "bun.lockb", "bun.lock", "poetry.lock", "cargo.lock", | |
| 120 | "composer.lock", "gemfile.lock", | |
| 121 | ]); | |
| 122 | if (lockFiles.has(basename)) return true; | |
| 123 | return false; | |
| 124 | } | |
| 125 | ||
| 126 | // --------------------------------------------------------------------------- | |
| 127 | // Git helpers | |
| 128 | // --------------------------------------------------------------------------- | |
| 129 | ||
| 130 | async function gitExec( | |
| 131 | cmd: string[], | |
| 132 | cwd: string | |
| 133 | ): Promise<{ stdout: string; exitCode: number }> { | |
| 134 | const proc = Bun.spawn(cmd, { | |
| 135 | cwd, | |
| 136 | env: process.env as Record<string, string>, | |
| 137 | stdout: "pipe", | |
| 138 | stderr: "pipe", | |
| 139 | }); | |
| 140 | const stdout = await new Response(proc.stdout).text(); | |
| 141 | const exitCode = await proc.exited; | |
| 142 | return { stdout, exitCode }; | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Run `git grep -l <keyword> HEAD --` and return matching file paths. | |
| 147 | */ | |
| 148 | async function grepForKeyword( | |
| 149 | ownerName: string, | |
| 150 | repoName: string, | |
| 151 | keyword: string | |
| 152 | ): Promise<string[]> { | |
| 153 | const repoDir = getRepoPath(ownerName, repoName); | |
| 154 | try { | |
| 155 | const { stdout, exitCode } = await gitExec( | |
| 156 | ["git", "grep", "-l", "-i", keyword, "HEAD", "--"], | |
| 157 | repoDir | |
| 158 | ); | |
| 159 | // exit code 1 = no matches (not an error) | |
| 160 | if (exitCode !== 0 && exitCode !== 1) return []; | |
| 161 | return stdout | |
| 162 | .trim() | |
| 163 | .split("\n") | |
| 164 | .filter(Boolean) | |
| 165 | .map((line) => { | |
| 166 | // git grep -l with a ref outputs "<ref>:<path>" or just "<path>" | |
| 167 | const idx = line.indexOf(":"); | |
| 168 | return idx >= 0 ? line.slice(idx + 1) : line; | |
| 169 | }) | |
| 170 | .filter((p) => !shouldSkipPath(p)); | |
| 171 | } catch { | |
| 172 | return []; | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | /** | |
| 177 | * Read a file from the repo at HEAD, capped to maxBytes. | |
| 178 | * Returns empty string on error. | |
| 179 | */ | |
| 180 | async function readFileFromGit( | |
| 181 | ownerName: string, | |
| 182 | repoName: string, | |
| 183 | filePath: string, | |
| 184 | maxBytes = 6 * 1024 | |
| 185 | ): Promise<string> { | |
| 186 | const repoDir = getRepoPath(ownerName, repoName); | |
| 187 | try { | |
| 188 | const { stdout, exitCode } = await gitExec( | |
| 189 | ["git", "show", `HEAD:${filePath}`], | |
| 190 | repoDir | |
| 191 | ); | |
| 192 | if (exitCode !== 0) return ""; | |
| 193 | return stdout.slice(0, maxBytes); | |
| 194 | } catch { | |
| 195 | return ""; | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | // --------------------------------------------------------------------------- | |
| 200 | // Step 1 — Extract keywords from the query via Claude | |
| 201 | // --------------------------------------------------------------------------- | |
| 202 | ||
| 203 | interface KeywordExtraction { | |
| 204 | keywords: string[]; | |
| 205 | fileTypes: string[]; | |
| 206 | } | |
| 207 | ||
| 208 | async function extractKeywords(query: string): Promise<KeywordExtraction> { | |
| 209 | const client = getAnthropic(); | |
| 210 | try { | |
| 211 | const msg = await client.messages.create({ | |
| 212 | model: MODEL_SONNET, | |
| 213 | max_tokens: 256, | |
| 214 | messages: [ | |
| 215 | { | |
| 216 | role: "user", | |
| 217 | content: | |
| 218 | `Extract 3-5 grep-friendly keywords from this natural language search query. ` + | |
| 219 | `Return JSON only, no prose: {"keywords": string[], "fileTypes": string[]}\n` + | |
| 220 | `Keywords should be short, concrete identifiers/patterns likely to appear in code. ` + | |
| 221 | `fileTypes is an optional list of file extensions (e.g. [".ts", ".tsx"]) to narrow the search. ` + | |
| 222 | `Return [] for fileTypes if the query is language-agnostic.\n\n` + | |
| 223 | `Query: ${query}`, | |
| 224 | }, | |
| 225 | ], | |
| 226 | }); | |
| 227 | const text = msg.content.find((b) => b.type === "text")?.text ?? ""; | |
| 228 | const parsed = parseJsonResponse<KeywordExtraction>(text); | |
| 229 | if (parsed && Array.isArray(parsed.keywords)) { | |
| 230 | return { | |
| 231 | keywords: parsed.keywords.slice(0, 5).filter((k) => typeof k === "string" && k.length > 0), | |
| 232 | fileTypes: Array.isArray(parsed.fileTypes) ? parsed.fileTypes : [], | |
| 233 | }; | |
| 234 | } | |
| 235 | } catch (err) { | |
| 236 | console.error("[nl-search] keyword extraction error:", err); | |
| 237 | } | |
| 238 | // Fallback: split query into words | |
| 239 | const words = query | |
| 240 | .split(/[^a-zA-Z0-9_]+/) | |
| 241 | .filter((w) => w.length >= 3) | |
| 242 | .slice(0, 5); | |
| 243 | return { keywords: words, fileTypes: [] }; | |
| 244 | } | |
| 245 | ||
| 246 | // --------------------------------------------------------------------------- | |
| 247 | // Step 2 — Gather candidate files via git grep | |
| 248 | // --------------------------------------------------------------------------- | |
| 249 | ||
| 250 | async function gatherCandidates( | |
| 251 | ownerName: string, | |
| 252 | repoName: string, | |
| 253 | repoId: string, | |
| 254 | extraction: KeywordExtraction, | |
| 255 | maxFiles = 40 | |
| 256 | ): Promise<string[]> { | |
| 257 | const seen = new Set<string>(); | |
| 258 | ||
| 259 | // Run git grep for each keyword (in parallel) | |
| 260 | const results = await Promise.allSettled( | |
| 261 | extraction.keywords.map((kw) => grepForKeyword(ownerName, repoName, kw)) | |
| 262 | ); | |
| 263 | ||
| 264 | for (const r of results) { | |
| 265 | if (r.status === "fulfilled") { | |
| 266 | for (const p of r.value) { | |
| 267 | seen.add(p); | |
| 268 | if (seen.size >= maxFiles * 2) break; | |
| 269 | } | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | // Apply file-type filter if provided | |
| 274 | let candidates = Array.from(seen); | |
| 275 | if (extraction.fileTypes.length > 0) { | |
| 276 | const filtered = candidates.filter((p) => | |
| 277 | extraction.fileTypes.some((ext) => p.endsWith(ext)) | |
| 278 | ); | |
| 279 | // Only narrow if we still have results | |
| 280 | if (filtered.length > 0) candidates = filtered; | |
| 281 | } | |
| 282 | ||
| 283 | candidates = candidates.slice(0, maxFiles); | |
| 284 | ||
| 285 | // Fallback: if git grep returned nothing, read from code_chunks table | |
| 286 | if (candidates.length === 0) { | |
| 287 | try { | |
| 288 | const rows = await db | |
| 289 | .select({ path: codeChunks.path }) | |
| 290 | .from(codeChunks) | |
| 291 | .where(eq(codeChunks.repositoryId, repoId)) | |
| 292 | .groupBy(codeChunks.path) | |
| 293 | .limit(maxFiles); | |
| 294 | candidates = rows.map((r) => r.path).filter((p) => !shouldSkipPath(p)); | |
| 295 | } catch { | |
| 296 | // DB unavailable — return empty | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 300 | return candidates; | |
| 301 | } | |
| 302 | ||
| 303 | // --------------------------------------------------------------------------- | |
| 304 | // Step 3 — Read file contents and build context | |
| 305 | // --------------------------------------------------------------------------- | |
| 306 | ||
| 307 | /** | |
| 308 | * For each candidate file, read content (capped at 6KB). | |
| 309 | * Build a combined context string where each file is prefixed with a header | |
| 310 | * showing the filename and line numbers, capped at 80KB total. | |
| 311 | */ | |
| 312 | async function buildContext( | |
| 313 | ownerName: string, | |
| 314 | repoName: string, | |
| 315 | candidates: string[], | |
| 316 | maxTotalBytes = 80 * 1024 | |
| 317 | ): Promise<{ contextStr: string; filesRead: string[] }> { | |
| 318 | const BATCH = 10; | |
| 319 | const fileContents: Array<{ path: string; content: string }> = []; | |
| 320 | ||
| 321 | for (let i = 0; i < candidates.length; i += BATCH) { | |
| 322 | const batch = candidates.slice(i, i + BATCH); | |
| 323 | const contents = await Promise.all( | |
| 324 | batch.map((p) => readFileFromGit(ownerName, repoName, p, 6144)) | |
| 325 | ); | |
| 326 | for (let j = 0; j < batch.length; j++) { | |
| 327 | if (contents[j]) { | |
| 328 | fileContents.push({ path: batch[j], content: contents[j] }); | |
| 329 | } | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | // Build numbered context string | |
| 334 | let contextStr = ""; | |
| 335 | const filesRead: string[] = []; | |
| 336 | ||
| 337 | for (const { path, content } of fileContents) { | |
| 338 | if (contextStr.length >= maxTotalBytes) break; | |
| 339 | const lines = content.split("\n"); | |
| 340 | // Number lines starting at 1 | |
| 341 | const numbered = lines | |
| 342 | .map((line, idx) => `${idx + 1}: ${line}`) | |
| 343 | .join("\n"); | |
| 344 | const header = `\n\n=== FILE: ${path} ===\n`; | |
| 345 | const block = header + numbered; | |
| 346 | if (contextStr.length + block.length > maxTotalBytes) { | |
| 347 | // Trim block to fit | |
| 348 | const remaining = maxTotalBytes - contextStr.length; | |
| 349 | if (remaining > header.length + 100) { | |
| 350 | contextStr += block.slice(0, remaining); | |
| 351 | filesRead.push(path); | |
| 352 | } | |
| 353 | } else { | |
| 354 | contextStr += block; | |
| 355 | filesRead.push(path); | |
| 356 | } | |
| 357 | } | |
| 358 | ||
| 359 | return { contextStr, filesRead }; | |
| 360 | } | |
| 361 | ||
| 362 | // --------------------------------------------------------------------------- | |
| 363 | // Step 4 — Claude reasoning pass | |
| 364 | // --------------------------------------------------------------------------- | |
| 365 | ||
| 366 | interface RawResult { | |
| 367 | filePath: string; | |
| 368 | lineStart: number; | |
| 369 | lineEnd: number; | |
| 370 | snippet: string; | |
| 371 | explanation: string; | |
| 372 | confidence: "high" | "medium" | "low"; | |
| 373 | } | |
| 374 | ||
| 375 | async function reasonWithClaude( | |
| 376 | query: string, | |
| 377 | contextStr: string | |
| 378 | ): Promise<NlSearchResult[]> { | |
| 379 | const client = getAnthropic(); | |
| 380 | ||
| 381 | const systemPrompt = | |
| 382 | `You are a code analysis expert. Find all places in the provided code that match the user's query. ` + | |
| 383 | `Be precise about file paths and line numbers. Only return matches that genuinely satisfy the query — ` + | |
| 384 | `do not include tangentially related code.`; | |
| 385 | ||
| 386 | const userPrompt = | |
| 387 | `Query: ${query}\n\n` + | |
| 388 | `Code files:\n${contextStr}\n\n` + | |
| 389 | `Return JSON only, no prose:\n` + | |
| 390 | `{\n` + | |
| 391 | ` "results": Array<{\n` + | |
| 392 | ` "filePath": string,\n` + | |
| 393 | ` "lineStart": number,\n` + | |
| 394 | ` "lineEnd": number,\n` + | |
| 395 | ` "snippet": string,\n` + | |
| 396 | ` "explanation": string,\n` + | |
| 397 | ` "confidence": "high" | "medium" | "low"\n` + | |
| 398 | ` }>\n` + | |
| 399 | `}\n\n` + | |
| 400 | `Return {"results": []} if nothing matches. Sort by confidence descending. Max 10 results.`; | |
| 401 | ||
| 402 | try { | |
| 403 | const msg = await client.messages.create({ | |
| 404 | model: MODEL_SONNET, | |
| 405 | max_tokens: 3000, | |
| 406 | system: systemPrompt, | |
| 407 | messages: [{ role: "user", content: userPrompt }], | |
| 408 | }); | |
| 409 | ||
| 410 | const text = msg.content.find((b) => b.type === "text")?.text ?? ""; | |
| 411 | const parsed = parseJsonResponse<{ results: RawResult[] }>(text); | |
| 412 | ||
| 413 | if (!parsed || !Array.isArray(parsed.results)) return []; | |
| 414 | ||
| 415 | return parsed.results | |
| 416 | .filter( | |
| 417 | (r): r is RawResult => | |
| 418 | typeof r.filePath === "string" && | |
| 419 | typeof r.lineStart === "number" && | |
| 420 | typeof r.lineEnd === "number" && | |
| 421 | typeof r.snippet === "string" && | |
| 422 | typeof r.explanation === "string" && | |
| 423 | (r.confidence === "high" || r.confidence === "medium" || r.confidence === "low") | |
| 424 | ) | |
| 425 | .slice(0, 10); | |
| 426 | } catch (err) { | |
| 427 | console.error("[nl-search] Claude reasoning error:", err); | |
| 428 | return []; | |
| 429 | } | |
| 430 | } | |
| 431 | ||
| 432 | // --------------------------------------------------------------------------- | |
| 433 | // Main export | |
| 434 | // --------------------------------------------------------------------------- | |
| 435 | ||
| 436 | /** | |
| 437 | * Natural language code search powered by Claude. | |
| 438 | * | |
| 439 | * @param ownerName - repo owner username | |
| 440 | * @param repoName - repo name | |
| 441 | * @param repoId - DB repository id (used as cache key + fallback) | |
| 442 | * @param query - natural-language search query | |
| 443 | * @returns NlSearchResponse (never throws) | |
| 444 | */ | |
| 445 | export async function nlSearch( | |
| 446 | ownerName: string, | |
| 447 | repoName: string, | |
| 448 | repoId: string, | |
| 449 | query: string | |
| 450 | ): Promise<NlSearchResponse> { | |
| 451 | const q = query.trim(); | |
| 452 | const empty: NlSearchResponse = { | |
| 453 | query: q, | |
| 454 | results: [], | |
| 455 | totalFilesScanned: 0, | |
| 456 | searchedAt: new Date(), | |
| 457 | cached: false, | |
| 458 | }; | |
| 459 | ||
| 460 | if (!q) return empty; | |
| 461 | ||
| 462 | // Guard: AI must be available | |
| 463 | if (!isAiAvailable()) { | |
| 464 | return { ...empty, results: [] }; | |
| 465 | } | |
| 466 | ||
| 467 | // Cache check | |
| 468 | const cacheKey = `${repoId}:${q}`; | |
| 469 | const cached = cacheGet(cacheKey); | |
| 470 | if (cached) { | |
| 471 | return { ...cached, cached: true }; | |
| 472 | } | |
| 473 | ||
| 474 | try { | |
| 475 | // Step 1 — Extract keywords | |
| 476 | const extraction = await extractKeywords(q); | |
| 477 | ||
| 478 | // Step 2 — Gather candidate files | |
| 479 | const candidates = await gatherCandidates( | |
| 480 | ownerName, repoName, repoId, extraction, 40 | |
| 481 | ); | |
| 482 | ||
| 483 | if (candidates.length === 0) { | |
| 484 | cacheSet(cacheKey, empty); | |
| 485 | return empty; | |
| 486 | } | |
| 487 | ||
| 488 | // Step 3 — Read file contents | |
| 489 | const { contextStr, filesRead } = await buildContext( | |
| 490 | ownerName, repoName, candidates, 80 * 1024 | |
| 491 | ); | |
| 492 | ||
| 493 | if (!contextStr || filesRead.length === 0) { | |
| 494 | cacheSet(cacheKey, empty); | |
| 495 | return empty; | |
| 496 | } | |
| 497 | ||
| 498 | // Step 4 — Claude reasoning pass | |
| 499 | const results = await reasonWithClaude(q, contextStr); | |
| 500 | ||
| 501 | const response: NlSearchResponse = { | |
| 502 | query: q, | |
| 503 | results, | |
| 504 | totalFilesScanned: filesRead.length, | |
| 505 | searchedAt: new Date(), | |
| 506 | cached: false, | |
| 507 | }; | |
| 508 | ||
| 509 | // Step 5 — Cache and return | |
| 510 | cacheSet(cacheKey, response); | |
| 511 | return response; | |
| 512 | } catch (err) { | |
| 513 | console.error("[nl-search] unexpected error:", err); | |
| 514 | return empty; | |
| 515 | } | |
| 516 | } |