* Handle codegraph_explore — deep exploration in a single call * * Strategy: find relevant symbols via graph traversal, group by file, * then read contiguous file sections covering all symbols per file. * This replaces multiple codegraph_node + Read calls. * * Output size is adapti
(args: Record<string, unknown>)
| 3947 | * tax on small projects while earning its keep on large ones. |
| 3948 | */ |
| 3949 | private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> { |
| 3950 | const rawQuery = this.validateString(args.query, 'query'); |
| 3951 | if (typeof rawQuery !== 'string') return rawQuery; |
| 3952 | // One normalization point so the flow-builder, relevance search, and |
| 3953 | // ranking all see the same canonical spelling (Erlang `mod:fn/arity`). |
| 3954 | const query = normalizeQuerySpelling(rawQuery); |
| 3955 | |
| 3956 | const cg = this.getCodeGraph(args.projectPath as string | undefined); |
| 3957 | const projectRoot = cg.getProjectRoot(); |
| 3958 | |
| 3959 | // Resolve adaptive output budget from project size. Falls back to the |
| 3960 | // largest-tier defaults if stats aren't available, which preserves |
| 3961 | // pre-#185 behavior for callers that hit the rare stats failure. |
| 3962 | let budget: ExploreOutputBudget; |
| 3963 | let indexedFileCount = -1; |
| 3964 | let indexedNodeCount = -1; |
| 3965 | try { |
| 3966 | const stats = cg.getStats(); |
| 3967 | indexedFileCount = stats.fileCount; |
| 3968 | indexedNodeCount = stats.nodeCount; |
| 3969 | budget = getExploreOutputBudget(indexedFileCount); |
| 3970 | } catch { |
| 3971 | budget = getExploreOutputBudget(Infinity); |
| 3972 | } |
| 3973 | const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20); |
| 3974 | |
| 3975 | // File paths named in the query become PINNED files: guaranteed admission, |
| 3976 | // top of the rank order, funded first — and their span is REMOVED from the |
| 3977 | // matching query. Runs on the RAW query (normalizeQuerySpelling strips |
| 3978 | // `/digits` tails, which would mangle numeric path segments). Without this, |
| 3979 | // a SvelteKit path like `runs/[runId]/+page.svelte` was shredded by the |
| 3980 | // seeding tokenizer (splits on brackets → `runId` seeded as a "named |
| 3981 | // symbol") and by FTS (`page`/`runs` fragments admitted every sibling |
| 3982 | // `+page.svelte`), starving the very files the agent asked for. |
| 3983 | let pinnedFiles: string[] = []; |
| 3984 | let unresolvedPathSpans: string[] = []; |
| 3985 | let lineAnchors: QueryLineAnchor[] = []; |
| 3986 | let setAsideMatches: QuerySetAsideMatch[] = []; |
| 3987 | let matchQuery = query; |
| 3988 | if (queryMightContainPaths(rawQuery)) { |
| 3989 | try { |
| 3990 | const extraction = extractQueryPaths( |
| 3991 | rawQuery, |
| 3992 | cg.getFiles().map((f) => f.path), |
| 3993 | { |
| 3994 | maxPins: maxFiles, |
| 3995 | existsOnDisk: (rel) => pathIsProjectFile(projectRoot, rel), |
| 3996 | symbolFiles: (symbol) => filesDefiningSymbol(cg, symbol), |
| 3997 | }, |
| 3998 | ); |
| 3999 | if (extraction.pinnedFiles.length > 0 || extraction.unresolvedPathSpans.length > 0) { |
| 4000 | pinnedFiles = extraction.pinnedFiles; |
| 4001 | unresolvedPathSpans = extraction.unresolvedPathSpans; |
| 4002 | lineAnchors = extraction.lineAnchors; |
| 4003 | setAsideMatches = extraction.setAsideMatches; |
| 4004 | matchQuery = normalizeQuerySpelling(extraction.strippedQuery); |
| 4005 | } |
| 4006 | } catch { /* path pinning must never fail an explore call */ } |
no test coverage detected