1
0

tools.ts 64 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739
  1. /**
  2. * MCP Tool Definitions
  3. *
  4. * Defines the tools exposed by the CodeGraph MCP server.
  5. */
  6. import CodeGraph, { findNearestCodeGraphRoot } from '../index';
  7. import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
  8. import { createHash } from 'crypto';
  9. import { writeFileSync, readFileSync, existsSync } from 'fs';
  10. import { clamp, validatePathWithinRoot } from '../utils';
  11. import { tmpdir } from 'os';
  12. import { join } from 'path';
  13. import { WASM_FALLBACK_FIX_RECIPE } from '../db';
  14. /** Maximum output length to prevent context bloat (characters) */
  15. const MAX_OUTPUT_LENGTH = 15000;
  16. /**
  17. * Rust path roots that have no file-system equivalent — `crate` is the
  18. * current crate, `super` is the parent module, `self` is the current
  19. * module. Used by `matchesSymbol` to strip these before file-path
  20. * matching so `crate::configurator::stage_apply::run` resolves the
  21. * same as `configurator::stage_apply::run`.
  22. */
  23. const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
  24. /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
  25. function lastQualifierPart(symbol: string): string {
  26. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  27. return parts[parts.length - 1] ?? symbol;
  28. }
  29. /**
  30. * Calculate the recommended number of codegraph_explore calls based on project size.
  31. * Larger codebases need more exploration calls to cover their surface area,
  32. * but smaller ones should use fewer to avoid unnecessary overhead.
  33. */
  34. export function getExploreBudget(fileCount: number): number {
  35. if (fileCount < 500) return 1;
  36. if (fileCount < 5000) return 2;
  37. if (fileCount < 15000) return 3;
  38. if (fileCount < 25000) return 4;
  39. return 5;
  40. }
  41. /**
  42. * Adaptive output budget for `codegraph_explore`, scaled to project size.
  43. *
  44. * Smaller codebases get a tighter total cap, fewer default files, smaller
  45. * per-file cap, and tighter clustering — so a focused query on a 100-file
  46. * project doesn't dump a whole file's worth of source into the agent's
  47. * context. Larger codebases keep the generous defaults because the
  48. * agent's native discovery cost (grep + find + many Reads) genuinely
  49. * dwarfs a fat explore call at that scale.
  50. *
  51. * Meta-text (relationships map, "additional relevant files" list,
  52. * completeness signal, budget note) is gated off for tiny projects
  53. * where one rich call is the whole story and the extra prose is just
  54. * overhead.
  55. *
  56. * Tier breakpoints mirror `getExploreBudget` so a project sits in the
  57. * same tier across both knobs.
  58. */
  59. export interface ExploreOutputBudget {
  60. /** Hard cap on total output characters. */
  61. maxOutputChars: number;
  62. /** Default `maxFiles` when the caller didn't specify one. */
  63. defaultMaxFiles: number;
  64. /** Cap on contiguous source returned per file (across all its clusters). */
  65. maxCharsPerFile: number;
  66. /** Cluster gap threshold in lines — tighter clustering on small projects. */
  67. gapThreshold: number;
  68. /** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */
  69. maxSymbolsInFileHeader: number;
  70. /** Max edges shown per relationship kind in the Relationships section. */
  71. maxEdgesPerRelationshipKind: number;
  72. /** Include the "Relationships" section. */
  73. includeRelationships: boolean;
  74. /** Include the "Additional relevant files (not shown)" trailing list. */
  75. includeAdditionalFiles: boolean;
  76. /** Include the "Complete source code is included above…" reminder. */
  77. includeCompletenessSignal: boolean;
  78. /** Include the explore-budget reminder at the end. */
  79. includeBudgetNote: boolean;
  80. }
  81. export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
  82. if (fileCount < 500) {
  83. return {
  84. maxOutputChars: 18000,
  85. defaultMaxFiles: 5,
  86. maxCharsPerFile: 3800,
  87. gapThreshold: 8,
  88. maxSymbolsInFileHeader: 6,
  89. maxEdgesPerRelationshipKind: 6,
  90. includeRelationships: true,
  91. includeAdditionalFiles: false,
  92. includeCompletenessSignal: false,
  93. includeBudgetNote: false,
  94. };
  95. }
  96. if (fileCount < 5000) {
  97. return {
  98. maxOutputChars: 28000,
  99. defaultMaxFiles: 9,
  100. maxCharsPerFile: 5000,
  101. gapThreshold: 12,
  102. maxSymbolsInFileHeader: 10,
  103. maxEdgesPerRelationshipKind: 10,
  104. includeRelationships: true,
  105. includeAdditionalFiles: true,
  106. includeCompletenessSignal: true,
  107. includeBudgetNote: true,
  108. };
  109. }
  110. if (fileCount < 15000) {
  111. return {
  112. maxOutputChars: 35000,
  113. defaultMaxFiles: 12,
  114. maxCharsPerFile: 7000,
  115. gapThreshold: 15,
  116. maxSymbolsInFileHeader: 15,
  117. maxEdgesPerRelationshipKind: 15,
  118. includeRelationships: true,
  119. includeAdditionalFiles: true,
  120. includeCompletenessSignal: true,
  121. includeBudgetNote: true,
  122. };
  123. }
  124. return {
  125. maxOutputChars: 38000,
  126. defaultMaxFiles: 14,
  127. maxCharsPerFile: 7000,
  128. gapThreshold: 15,
  129. maxSymbolsInFileHeader: 15,
  130. maxEdgesPerRelationshipKind: 15,
  131. includeRelationships: true,
  132. includeAdditionalFiles: true,
  133. includeCompletenessSignal: true,
  134. includeBudgetNote: true,
  135. };
  136. }
  137. /**
  138. * Whether `codegraph_explore` should prefix source lines with their line
  139. * numbers (cat -n style: `<num>\t<code>`).
  140. *
  141. * Line numbers let the agent cite `file:line` straight from the explore
  142. * payload instead of re-Reading the file just to find a line number — the
  143. * dominant residual cost on precise-tracing questions (#185 follow-up).
  144. *
  145. * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
  146. * A/B harness to measure the payload-cost vs. read-savings tradeoff).
  147. */
  148. function exploreLineNumbersEnabled(): boolean {
  149. return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
  150. }
  151. /**
  152. * Prefix each line of a source slice with its 1-based line number, matching
  153. * the Read tool's `cat -n` convention (number + tab) so the agent treats it
  154. * the same way it treats Read output.
  155. *
  156. * @param slice contiguous source text (already extracted from the file)
  157. * @param firstLineNumber the 1-based line number of the slice's first line
  158. */
  159. function numberSourceLines(slice: string, firstLineNumber: number): string {
  160. const out: string[] = [];
  161. const split = slice.split('\n');
  162. for (let i = 0; i < split.length; i++) {
  163. out.push(`${firstLineNumber + i}\t${split[i]}`);
  164. }
  165. return out.join('\n');
  166. }
  167. /**
  168. * Mark a Claude session as having consulted MCP tools.
  169. * This enables Grep/Glob/Bash commands that would otherwise be blocked.
  170. */
  171. function markSessionConsulted(sessionId: string): void {
  172. try {
  173. const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
  174. const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
  175. writeFileSync(markerPath, new Date().toISOString(), 'utf8');
  176. } catch {
  177. // Silently fail - don't break MCP on marker write failure
  178. }
  179. }
  180. /**
  181. * MCP Tool definition
  182. */
  183. export interface ToolDefinition {
  184. name: string;
  185. description: string;
  186. inputSchema: {
  187. type: 'object';
  188. properties: Record<string, PropertySchema>;
  189. required?: string[];
  190. };
  191. }
  192. interface PropertySchema {
  193. type: string;
  194. description: string;
  195. enum?: string[];
  196. default?: unknown;
  197. }
  198. /**
  199. * Tool execution result
  200. */
  201. export interface ToolResult {
  202. content: Array<{
  203. type: 'text';
  204. text: string;
  205. }>;
  206. isError?: boolean;
  207. }
  208. /**
  209. * Common projectPath property for cross-project queries
  210. */
  211. const projectPathProperty: PropertySchema = {
  212. type: 'string',
  213. description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
  214. };
  215. /**
  216. * All CodeGraph MCP tools
  217. *
  218. * Designed for minimal context usage - use codegraph_context as the primary tool,
  219. * and only use other tools for targeted follow-up queries.
  220. *
  221. * All tools support cross-project queries via the optional `projectPath` parameter.
  222. */
  223. export const tools: ToolDefinition[] = [
  224. {
  225. name: 'codegraph_search',
  226. description: 'Quick symbol search by name. Returns locations only (no code) — best for pinpoint "where is X defined / find the symbol named X" lookups. For understanding how something works or tracing a flow, lead with codegraph_explore instead of searching then reading.',
  227. inputSchema: {
  228. type: 'object',
  229. properties: {
  230. query: {
  231. type: 'string',
  232. description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
  233. },
  234. kind: {
  235. type: 'string',
  236. description: 'Filter by node kind',
  237. enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
  238. },
  239. limit: {
  240. type: 'number',
  241. description: 'Maximum results (default: 10)',
  242. default: 10,
  243. },
  244. projectPath: projectPathProperty,
  245. },
  246. required: ['query'],
  247. },
  248. },
  249. {
  250. name: 'codegraph_context',
  251. description: 'PRIMARY TOOL: Build comprehensive context for a task. Returns entry points, related symbols, and key code - often enough to understand the codebase without additional tool calls. NOTE: This provides CODE context, not product requirements. For new features, still clarify UX/behavior questions with the user before implementing.',
  252. inputSchema: {
  253. type: 'object',
  254. properties: {
  255. task: {
  256. type: 'string',
  257. description: 'Description of the task, bug, or feature to build context for',
  258. },
  259. maxNodes: {
  260. type: 'number',
  261. description: 'Maximum symbols to include (default: 20)',
  262. default: 20,
  263. },
  264. includeCode: {
  265. type: 'boolean',
  266. description: 'Include code snippets for key symbols (default: true)',
  267. default: true,
  268. },
  269. projectPath: projectPathProperty,
  270. },
  271. required: ['task'],
  272. },
  273. },
  274. {
  275. name: 'codegraph_callers',
  276. description: 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.',
  277. inputSchema: {
  278. type: 'object',
  279. properties: {
  280. symbol: {
  281. type: 'string',
  282. description: 'Name of the function, method, or class to find callers for',
  283. },
  284. limit: {
  285. type: 'number',
  286. description: 'Maximum number of callers to return (default: 20)',
  287. default: 20,
  288. },
  289. projectPath: projectPathProperty,
  290. },
  291. required: ['symbol'],
  292. },
  293. },
  294. {
  295. name: 'codegraph_callees',
  296. description: 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.',
  297. inputSchema: {
  298. type: 'object',
  299. properties: {
  300. symbol: {
  301. type: 'string',
  302. description: 'Name of the function, method, or class to find callees for',
  303. },
  304. limit: {
  305. type: 'number',
  306. description: 'Maximum number of callees to return (default: 20)',
  307. default: 20,
  308. },
  309. projectPath: projectPathProperty,
  310. },
  311. required: ['symbol'],
  312. },
  313. },
  314. {
  315. name: 'codegraph_impact',
  316. description: 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.',
  317. inputSchema: {
  318. type: 'object',
  319. properties: {
  320. symbol: {
  321. type: 'string',
  322. description: 'Name of the symbol to analyze impact for',
  323. },
  324. depth: {
  325. type: 'number',
  326. description: 'How many levels of dependencies to traverse (default: 2)',
  327. default: 2,
  328. },
  329. projectPath: projectPathProperty,
  330. },
  331. required: ['symbol'],
  332. },
  333. },
  334. {
  335. name: 'codegraph_node',
  336. description: 'Get detailed information about a specific code symbol. Use includeCode=true only when you need the full source code - otherwise just get location and signature to minimize context usage.',
  337. inputSchema: {
  338. type: 'object',
  339. properties: {
  340. symbol: {
  341. type: 'string',
  342. description: 'Name of the symbol to get details for',
  343. },
  344. includeCode: {
  345. type: 'boolean',
  346. description: 'Include full source code (default: false to minimize context)',
  347. default: false,
  348. },
  349. projectPath: projectPathProperty,
  350. },
  351. required: ['symbol'],
  352. },
  353. },
  354. {
  355. name: 'codegraph_explore',
  356. description: 'PRIMARY TOOL for understanding questions — "how does X work", "trace X end to end", "explain the Y system", architecture/onboarding. Returns comprehensive context in a SINGLE call: relevant source grouped by file (contiguous, line-numbered sections, not snippets) + a relationship map + deep graph traversal. It REPLACES the grep+Read exploration loop: feed it the key symbol/file names and read its output — do NOT Read the files one by one. It works best when your query names the relevant symbols (e.g. "readAgentsFromDirectory createClaudeSession chat-manager agents.ts"); if the question is a plain sentence that names nothing concrete, do ONE quick codegraph_search or codegraph_context to surface the names, then call this with them. After exploring, use codegraph_node / Read only to fill specific gaps it did not cover. Prefer codegraph_search over this only for a pinpoint "where is X defined" lookup.',
  357. inputSchema: {
  358. type: 'object',
  359. properties: {
  360. query: {
  361. type: 'string',
  362. description: 'What to explore. A short list of symbol/file/keyword terms works best (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"), but a plain-language phrase also works — the tool runs its own retrieval. No need to codegraph_search first.',
  363. },
  364. maxFiles: {
  365. type: 'number',
  366. description: 'Maximum number of files to include source code from (default: 12)',
  367. default: 12,
  368. },
  369. projectPath: projectPathProperty,
  370. },
  371. required: ['query'],
  372. },
  373. },
  374. {
  375. name: 'codegraph_status',
  376. description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.',
  377. inputSchema: {
  378. type: 'object',
  379. properties: {
  380. projectPath: projectPathProperty,
  381. },
  382. },
  383. },
  384. {
  385. name: 'codegraph_files',
  386. description: 'REQUIRED for file/folder exploration. Get the project file structure from the CodeGraph index. Returns a tree view of all indexed files with metadata (language, symbol count). Much faster than Glob/filesystem scanning. Use this FIRST when exploring project structure, finding files, or understanding codebase organization.',
  387. inputSchema: {
  388. type: 'object',
  389. properties: {
  390. path: {
  391. type: 'string',
  392. description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
  393. },
  394. pattern: {
  395. type: 'string',
  396. description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
  397. },
  398. format: {
  399. type: 'string',
  400. description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
  401. enum: ['tree', 'flat', 'grouped'],
  402. default: 'tree',
  403. },
  404. includeMetadata: {
  405. type: 'boolean',
  406. description: 'Include file metadata like language and symbol count (default: true)',
  407. default: true,
  408. },
  409. maxDepth: {
  410. type: 'number',
  411. description: 'Maximum directory depth to show (default: unlimited)',
  412. },
  413. projectPath: projectPathProperty,
  414. },
  415. },
  416. },
  417. ];
  418. /**
  419. * Tool handler that executes tools against a CodeGraph instance
  420. *
  421. * Supports cross-project queries via the projectPath parameter.
  422. * Other projects are opened on-demand and cached for performance.
  423. */
  424. export class ToolHandler {
  425. // Cache of opened CodeGraph instances for cross-project queries
  426. private projectCache: Map<string, CodeGraph> = new Map();
  427. constructor(private cg: CodeGraph | null) {}
  428. /**
  429. * Update the default CodeGraph instance (e.g. after lazy initialization)
  430. */
  431. setDefaultCodeGraph(cg: CodeGraph): void {
  432. this.cg = cg;
  433. }
  434. /**
  435. * Whether a default CodeGraph instance is available
  436. */
  437. hasDefaultCodeGraph(): boolean {
  438. return this.cg !== null;
  439. }
  440. /**
  441. * Get tool definitions with dynamic descriptions based on project size.
  442. * The codegraph_explore tool description includes a budget recommendation
  443. * scaled to the number of indexed files.
  444. */
  445. getTools(): ToolDefinition[] {
  446. if (!this.cg) return tools;
  447. try {
  448. const stats = this.cg.getStats();
  449. const budget = getExploreBudget(stats.fileCount);
  450. return tools.map(tool => {
  451. if (tool.name === 'codegraph_explore') {
  452. return {
  453. ...tool,
  454. description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
  455. };
  456. }
  457. return tool;
  458. });
  459. } catch {
  460. return tools;
  461. }
  462. }
  463. /**
  464. * Get CodeGraph instance for a project
  465. *
  466. * If projectPath is provided, opens that project's CodeGraph (cached).
  467. * Otherwise returns the default CodeGraph instance.
  468. *
  469. * Walks up parent directories to find the nearest .codegraph/ folder,
  470. * similar to how git finds .git/ directories.
  471. */
  472. private getCodeGraph(projectPath?: string): CodeGraph {
  473. if (!projectPath) {
  474. if (!this.cg) {
  475. throw new Error('CodeGraph not initialized for this project. Run \'codegraph init\' first.');
  476. }
  477. return this.cg;
  478. }
  479. // Check cache first (using original path as key)
  480. if (this.projectCache.has(projectPath)) {
  481. return this.projectCache.get(projectPath)!;
  482. }
  483. // Walk up parent directories to find nearest .codegraph/
  484. const resolvedRoot = findNearestCodeGraphRoot(projectPath);
  485. if (!resolvedRoot) {
  486. throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
  487. }
  488. // Check if we already have this resolved root cached (different path, same project)
  489. if (this.projectCache.has(resolvedRoot)) {
  490. const cg = this.projectCache.get(resolvedRoot)!;
  491. // Cache under original path too for faster future lookups
  492. this.projectCache.set(projectPath, cg);
  493. return cg;
  494. }
  495. // Open and cache under both paths
  496. const cg = CodeGraph.openSync(resolvedRoot);
  497. this.projectCache.set(resolvedRoot, cg);
  498. if (projectPath !== resolvedRoot) {
  499. this.projectCache.set(projectPath, cg);
  500. }
  501. return cg;
  502. }
  503. /**
  504. * Close all cached project connections
  505. */
  506. closeAll(): void {
  507. for (const cg of this.projectCache.values()) {
  508. cg.close();
  509. }
  510. this.projectCache.clear();
  511. }
  512. /**
  513. * Validate that a value is a non-empty string
  514. */
  515. private validateString(value: unknown, name: string): string | ToolResult {
  516. if (typeof value !== 'string' || value.length === 0) {
  517. return this.errorResult(`${name} must be a non-empty string`);
  518. }
  519. return value;
  520. }
  521. /**
  522. * Execute a tool by name
  523. */
  524. async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  525. try {
  526. switch (toolName) {
  527. case 'codegraph_search':
  528. return await this.handleSearch(args);
  529. case 'codegraph_context':
  530. return await this.handleContext(args);
  531. case 'codegraph_callers':
  532. return await this.handleCallers(args);
  533. case 'codegraph_callees':
  534. return await this.handleCallees(args);
  535. case 'codegraph_impact':
  536. return await this.handleImpact(args);
  537. case 'codegraph_explore':
  538. return await this.handleExplore(args);
  539. case 'codegraph_node':
  540. return await this.handleNode(args);
  541. case 'codegraph_status':
  542. return await this.handleStatus(args);
  543. case 'codegraph_files':
  544. return await this.handleFiles(args);
  545. default:
  546. return this.errorResult(`Unknown tool: ${toolName}`);
  547. }
  548. } catch (err) {
  549. return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
  550. }
  551. }
  552. /**
  553. * Handle codegraph_search
  554. */
  555. private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
  556. const query = this.validateString(args.query, 'query');
  557. if (typeof query !== 'string') return query;
  558. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  559. const kind = args.kind as string | undefined;
  560. const rawLimit = Number(args.limit) || 10;
  561. const limit = clamp(rawLimit, 1, 100);
  562. const results = cg.searchNodes(query, {
  563. limit,
  564. kinds: kind ? [kind as NodeKind] : undefined,
  565. });
  566. if (results.length === 0) {
  567. return this.textResult(`No results found for "${query}"`);
  568. }
  569. const formatted = this.formatSearchResults(results);
  570. return this.textResult(this.truncateOutput(formatted));
  571. }
  572. /**
  573. * Handle codegraph_context
  574. */
  575. private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
  576. const task = this.validateString(args.task, 'task');
  577. if (typeof task !== 'string') return task;
  578. // Mark session as consulted (enables Grep/Glob/Bash)
  579. const sessionId = process.env.CLAUDE_SESSION_ID;
  580. if (sessionId) {
  581. markSessionConsulted(sessionId);
  582. }
  583. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  584. const maxNodes = (args.maxNodes as number) || 20;
  585. const includeCode = args.includeCode !== false;
  586. const context = await cg.buildContext(task, {
  587. maxNodes,
  588. includeCode,
  589. format: 'markdown',
  590. });
  591. // Detect if this looks like a feature request (vs bug fix or exploration)
  592. const isFeatureQuery = this.looksLikeFeatureRequest(task);
  593. const reminder = isFeatureQuery
  594. ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
  595. : '';
  596. // buildContext returns string when format is 'markdown'
  597. if (typeof context === 'string') {
  598. return this.textResult(context + reminder);
  599. }
  600. // If it returns TaskContext, format it
  601. return this.textResult(this.formatTaskContext(context) + reminder);
  602. }
  603. /**
  604. * Heuristic to detect if a query looks like a feature request
  605. */
  606. private looksLikeFeatureRequest(task: string): boolean {
  607. const featureKeywords = [
  608. 'add', 'create', 'implement', 'build', 'enable', 'allow',
  609. 'new feature', 'support for', 'ability to', 'want to',
  610. 'should be able', 'need to add', 'swap', 'edit', 'modify'
  611. ];
  612. const bugKeywords = [
  613. 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
  614. 'not working', 'fails', 'undefined', 'null'
  615. ];
  616. const explorationKeywords = [
  617. 'how does', 'where is', 'what is', 'find', 'show me',
  618. 'explain', 'understand', 'explore'
  619. ];
  620. const lowerTask = task.toLowerCase();
  621. // If it's clearly a bug or exploration, not a feature
  622. if (bugKeywords.some(k => lowerTask.includes(k))) return false;
  623. if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
  624. // If it matches feature keywords, it's likely a feature request
  625. return featureKeywords.some(k => lowerTask.includes(k));
  626. }
  627. /**
  628. * Handle codegraph_callers
  629. */
  630. private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
  631. const symbol = this.validateString(args.symbol, 'symbol');
  632. if (typeof symbol !== 'string') return symbol;
  633. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  634. const limit = clamp((args.limit as number) || 20, 1, 100);
  635. const allMatches = this.findAllSymbols(cg, symbol);
  636. if (allMatches.nodes.length === 0) {
  637. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  638. }
  639. // Aggregate callers across all matching symbols
  640. const seen = new Set<string>();
  641. const allCallers: Node[] = [];
  642. for (const node of allMatches.nodes) {
  643. for (const c of cg.getCallers(node.id)) {
  644. if (!seen.has(c.node.id)) {
  645. seen.add(c.node.id);
  646. allCallers.push(c.node);
  647. }
  648. }
  649. }
  650. if (allCallers.length === 0) {
  651. return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
  652. }
  653. const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
  654. return this.textResult(this.truncateOutput(formatted));
  655. }
  656. /**
  657. * Handle codegraph_callees
  658. */
  659. private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
  660. const symbol = this.validateString(args.symbol, 'symbol');
  661. if (typeof symbol !== 'string') return symbol;
  662. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  663. const limit = clamp((args.limit as number) || 20, 1, 100);
  664. const allMatches = this.findAllSymbols(cg, symbol);
  665. if (allMatches.nodes.length === 0) {
  666. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  667. }
  668. // Aggregate callees across all matching symbols
  669. const seen = new Set<string>();
  670. const allCallees: Node[] = [];
  671. for (const node of allMatches.nodes) {
  672. for (const c of cg.getCallees(node.id)) {
  673. if (!seen.has(c.node.id)) {
  674. seen.add(c.node.id);
  675. allCallees.push(c.node);
  676. }
  677. }
  678. }
  679. if (allCallees.length === 0) {
  680. return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
  681. }
  682. const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
  683. return this.textResult(this.truncateOutput(formatted));
  684. }
  685. /**
  686. * Handle codegraph_impact
  687. */
  688. private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
  689. const symbol = this.validateString(args.symbol, 'symbol');
  690. if (typeof symbol !== 'string') return symbol;
  691. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  692. const depth = clamp((args.depth as number) || 2, 1, 10);
  693. const allMatches = this.findAllSymbols(cg, symbol);
  694. if (allMatches.nodes.length === 0) {
  695. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  696. }
  697. // Aggregate impact across all matching symbols
  698. const mergedNodes = new Map<string, Node>();
  699. const mergedEdges: Edge[] = [];
  700. const seenEdges = new Set<string>();
  701. for (const node of allMatches.nodes) {
  702. const impact = cg.getImpactRadius(node.id, depth);
  703. for (const [id, n] of impact.nodes) {
  704. mergedNodes.set(id, n);
  705. }
  706. for (const e of impact.edges) {
  707. const key = `${e.source}->${e.target}:${e.kind}`;
  708. if (!seenEdges.has(key)) {
  709. seenEdges.add(key);
  710. mergedEdges.push(e);
  711. }
  712. }
  713. }
  714. const mergedImpact = {
  715. nodes: mergedNodes,
  716. edges: mergedEdges,
  717. roots: allMatches.nodes.map(n => n.id),
  718. };
  719. const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
  720. return this.textResult(this.truncateOutput(formatted));
  721. }
  722. /**
  723. * Handle codegraph_explore — deep exploration in a single call
  724. *
  725. * Strategy: find relevant symbols via graph traversal, group by file,
  726. * then read contiguous file sections covering all symbols per file.
  727. * This replaces multiple codegraph_node + Read calls.
  728. *
  729. * Output size is adaptive to project file count via
  730. * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
  731. * tax on small projects while earning its keep on large ones.
  732. */
  733. private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
  734. const query = this.validateString(args.query, 'query');
  735. if (typeof query !== 'string') return query;
  736. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  737. const projectRoot = cg.getProjectRoot();
  738. // Resolve adaptive output budget from project size. Falls back to the
  739. // largest-tier defaults if stats aren't available, which preserves
  740. // pre-#185 behavior for callers that hit the rare stats failure.
  741. let budget: ExploreOutputBudget;
  742. try {
  743. budget = getExploreOutputBudget(cg.getStats().fileCount);
  744. } catch {
  745. budget = getExploreOutputBudget(Infinity);
  746. }
  747. const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
  748. // Step 1: Find relevant context with generous parameters.
  749. // Use a large maxNodes budget — explore has its own 35k char output limit
  750. // that prevents context bloat, so more nodes just means better coverage
  751. // across entry points (especially for large files like Svelte components).
  752. const subgraph = await cg.findRelevantContext(query, {
  753. searchLimit: 8,
  754. traversalDepth: 3,
  755. maxNodes: 200,
  756. minScore: 0.2,
  757. });
  758. if (subgraph.nodes.size === 0) {
  759. return this.textResult(`No relevant code found for "${query}"`);
  760. }
  761. // Step 2: Group nodes by file, score by relevance
  762. const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
  763. const entryNodeIds = new Set(subgraph.roots);
  764. // Build a set of nodes directly connected to entry points (depth 1)
  765. const connectedToEntry = new Set<string>();
  766. for (const edge of subgraph.edges) {
  767. if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
  768. if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
  769. }
  770. for (const node of subgraph.nodes.values()) {
  771. // Skip import/export nodes — they add noise without information
  772. if (node.kind === 'import' || node.kind === 'export') continue;
  773. const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
  774. group.nodes.push(node);
  775. // Score: entry point nodes worth 10, directly connected worth 3, others worth 1
  776. if (entryNodeIds.has(node.id)) {
  777. group.score += 10;
  778. } else if (connectedToEntry.has(node.id)) {
  779. group.score += 3;
  780. } else {
  781. group.score += 1;
  782. }
  783. fileGroups.set(node.filePath, group);
  784. }
  785. // Only include files that have entry points or nodes directly connected to entry points
  786. const relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
  787. // Extract query terms for relevance checking
  788. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  789. // Sort files: highest relevance first, deprioritize low-value files
  790. const sortedFiles = relevantFiles.sort((a, b) => {
  791. const aPath = a[0].toLowerCase();
  792. const bPath = b[0].toLowerCase();
  793. // Check if any node name or file path relates to query terms
  794. const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
  795. const fp = filePath.toLowerCase();
  796. if (queryTerms.some(t => fp.includes(t))) return true;
  797. return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
  798. };
  799. const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
  800. const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
  801. if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
  802. // Deprioritize test files, icon files, and i18n files
  803. const isLowValue = (p: string) =>
  804. /\/(tests?|__tests?__|spec)\//i.test(p) ||
  805. /\bicons?\b/i.test(p) ||
  806. /\bi18n\b/i.test(p);
  807. const aLow = isLowValue(aPath);
  808. const bLow = isLowValue(bPath);
  809. if (aLow !== bLow) return aLow ? 1 : -1;
  810. if (a[1].score !== b[1].score) return b[1].score - a[1].score;
  811. return b[1].nodes.length - a[1].nodes.length;
  812. });
  813. // Step 3: Build relationship map
  814. const lines: string[] = [
  815. `## Exploration: ${query}`,
  816. '',
  817. `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
  818. '',
  819. ];
  820. // Relationship map — show how symbols connect
  821. const significantEdges = subgraph.edges.filter(e =>
  822. e.kind !== 'contains' // skip contains — it's implied by file grouping
  823. );
  824. if (budget.includeRelationships && significantEdges.length > 0) {
  825. lines.push('### Relationships');
  826. lines.push('');
  827. // Group edges by kind for readability
  828. const byKind = new Map<string, Array<{ source: string; target: string }>>();
  829. for (const edge of significantEdges) {
  830. const sourceNode = subgraph.nodes.get(edge.source);
  831. const targetNode = subgraph.nodes.get(edge.target);
  832. if (!sourceNode || !targetNode) continue;
  833. const group = byKind.get(edge.kind) || [];
  834. group.push({ source: sourceNode.name, target: targetNode.name });
  835. byKind.set(edge.kind, group);
  836. }
  837. for (const [kind, edges] of byKind) {
  838. const cap = budget.maxEdgesPerRelationshipKind;
  839. const shown = edges.slice(0, cap);
  840. lines.push(`**${kind}:**`);
  841. for (const e of shown) {
  842. lines.push(`- ${e.source} → ${e.target}`);
  843. }
  844. if (edges.length > cap) {
  845. lines.push(`- ... and ${edges.length - cap} more`);
  846. }
  847. lines.push('');
  848. }
  849. }
  850. // Step 4: Read contiguous file sections
  851. lines.push('### Source Code');
  852. lines.push('');
  853. let totalChars = lines.join('\n').length;
  854. let filesIncluded = 0;
  855. let anyFileTrimmed = false;
  856. for (const [filePath, group] of sortedFiles) {
  857. if (filesIncluded >= maxFiles) break;
  858. if (totalChars > budget.maxOutputChars * 0.9) break;
  859. const absPath = validatePathWithinRoot(projectRoot, filePath);
  860. if (!absPath || !existsSync(absPath)) continue;
  861. let fileContent: string;
  862. try {
  863. fileContent = readFileSync(absPath, 'utf-8');
  864. } catch {
  865. continue;
  866. }
  867. const fileLines = fileContent.split('\n');
  868. const lang = group.nodes[0]?.language || '';
  869. // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
  870. // Sort by start line, then merge overlapping/adjacent ranges (within the
  871. // adaptive gap threshold). Include both node ranges AND edge source
  872. // locations so template sections with component usages/calls are
  873. // covered (not just script block symbols).
  874. //
  875. // Each range carries an `importance` score so we can rank clusters
  876. // when the per-file budget forces us to drop some: entry-point nodes
  877. // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
  878. // bare edge-source lines 2 (less than a connected node but more than
  879. // a peripheral one — they hint at a reference but aren't a definition).
  880. // Container kinds whose body can span most/all of a file. When such a
  881. // node covers most of the file we drop it from the ranges: keeping it
  882. // would merge every method inside it into one giant cluster spanning
  883. // the whole file, which then tail-trims down to just the container's
  884. // opening lines (its header/declarations) and buries the methods the
  885. // query actually asked about (#185 follow-up — Session.swift in
  886. // Alamofire is the canonical case: the `Session` class spans ~1,400
  887. // lines). We want the granular symbols inside, not the envelope.
  888. const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
  889. const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
  890. .filter(n => n.startLine > 0 && n.endLine > 0)
  891. // Drop whole-file envelope nodes (containers covering >50% of the file).
  892. .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
  893. .map(n => {
  894. let importance = 1;
  895. if (entryNodeIds.has(n.id)) importance = 10;
  896. else if (connectedToEntry.has(n.id)) importance = 3;
  897. return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
  898. });
  899. // Add edge source locations in this file — captures template references
  900. // (component usages, event handlers) that aren't nodes themselves.
  901. // Query edges directly from the DB (not just the subgraph) because BFS
  902. // traversal may have pruned template reference targets due to node budget.
  903. const edgeLines = new Set<string>(); // dedup by "line:name"
  904. for (const node of group.nodes) {
  905. const outgoing = cg.getOutgoingEdges(node.id);
  906. for (const edge of outgoing) {
  907. if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
  908. const key = `${edge.line}:${edge.target}`;
  909. if (edgeLines.has(key)) continue;
  910. edgeLines.add(key);
  911. // Look up target name from subgraph first, fall back to edge kind
  912. const targetNode = subgraph.nodes.get(edge.target);
  913. const targetName = targetNode?.name ?? edge.kind;
  914. ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
  915. }
  916. }
  917. ranges.sort((a, b) => a.start - b.start);
  918. if (ranges.length === 0) continue;
  919. const gapThreshold = budget.gapThreshold;
  920. const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
  921. let current = {
  922. start: ranges[0]!.start,
  923. end: ranges[0]!.end,
  924. symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
  925. score: ranges[0]!.importance,
  926. maxImportance: ranges[0]!.importance,
  927. };
  928. for (let i = 1; i < ranges.length; i++) {
  929. const r = ranges[i]!;
  930. if (r.start <= current.end + gapThreshold) {
  931. current.end = Math.max(current.end, r.end);
  932. current.symbols.push(`${r.name}(${r.kind})`);
  933. current.score += r.importance;
  934. current.maxImportance = Math.max(current.maxImportance, r.importance);
  935. } else {
  936. clusters.push(current);
  937. current = {
  938. start: r.start,
  939. end: r.end,
  940. symbols: [`${r.name}(${r.kind})`],
  941. score: r.importance,
  942. maxImportance: r.importance,
  943. };
  944. }
  945. }
  946. clusters.push(current);
  947. // Build file section output from clusters, capped by per-file budget.
  948. // The pathological case (#185): a file like Session.swift where every
  949. // method is adjacent collapses into one cluster spanning the whole
  950. // file, and dumping that into the agent's context is most of the
  951. // token cost on small projects. We pick clusters in priority order
  952. // until the per-file char cap is hit. Truly enormous single clusters
  953. // get tail-trimmed with a marker.
  954. const contextPadding = 3;
  955. const withLineNumbers = exploreLineNumbersEnabled();
  956. const buildSection = (c: { start: number; end: number }): string => {
  957. const startIdx = Math.max(0, c.start - 1 - contextPadding);
  958. const endIdx = Math.min(fileLines.length, c.end + contextPadding);
  959. const slice = fileLines.slice(startIdx, endIdx).join('\n');
  960. // startIdx is 0-based, so the slice's first line is line startIdx + 1.
  961. return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
  962. };
  963. // Language-neutral separator (no `//` — not a comment in Python, Ruby,
  964. // etc.). With line numbers on, the line-number jump also signals the gap.
  965. const GAP_MARKER = '\n\n... (gap) ...\n\n';
  966. // Rank clusters for inclusion under the per-file cap. Entry-point
  967. // clusters come first: a cluster containing a query entry point
  968. // (importance 10) must outrank a dense block of mere declarations,
  969. // otherwise on a large file like Session.swift the top-of-file class
  970. // header + property list (many adjacent low-importance nodes, high
  971. // density) wins the budget and buries the actual methods the query
  972. // asked about (perform/didCreateURLRequest/task live deep in the
  973. // file). Within the same importance tier, prefer density (score per
  974. // line) so we still favor focused clusters over sprawling ones, then
  975. // smaller span as a cheap-to-include tiebreak.
  976. const rankedClusters = clusters
  977. .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
  978. .sort((a, b) => {
  979. if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
  980. const densityA = a.c.score / a.span;
  981. const densityB = b.c.score / b.span;
  982. if (densityB !== densityA) return densityB - densityA;
  983. if (b.c.score !== a.c.score) return b.c.score - a.c.score;
  984. return a.span - b.span;
  985. });
  986. const chosenIndices = new Set<number>();
  987. let projectedChars = 0;
  988. for (const rc of rankedClusters) {
  989. const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
  990. // Always take the top-ranked cluster, even if oversize, so we don't
  991. // return an empty file section (agent would then re-Read the file,
  992. // negating the savings).
  993. if (chosenIndices.size === 0) {
  994. chosenIndices.add(rc.idx);
  995. projectedChars += sectionLen;
  996. continue;
  997. }
  998. if (projectedChars + sectionLen > budget.maxCharsPerFile) continue;
  999. chosenIndices.add(rc.idx);
  1000. projectedChars += sectionLen;
  1001. }
  1002. // Emit chosen clusters in source order so the file reads top-to-bottom.
  1003. let fileSection = '';
  1004. const allSymbols: string[] = [];
  1005. let fileTrimmed = false;
  1006. for (let i = 0; i < clusters.length; i++) {
  1007. if (!chosenIndices.has(i)) continue;
  1008. const cluster = clusters[i]!;
  1009. const section = buildSection(cluster);
  1010. if (fileSection.length > 0) fileSection += GAP_MARKER;
  1011. fileSection += section;
  1012. allSymbols.push(...cluster.symbols);
  1013. }
  1014. // If a single chosen cluster is still oversize (long monolithic
  1015. // function), tail-trim it. Better one trimmed view than nothing.
  1016. if (fileSection.length > budget.maxCharsPerFile) {
  1017. fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
  1018. fileTrimmed = true;
  1019. }
  1020. if (chosenIndices.size < clusters.length || fileTrimmed) {
  1021. anyFileTrimmed = true;
  1022. }
  1023. // Dedupe + cap the symbols list shown in the per-file header. Some
  1024. // files (Session.swift in Alamofire) produced 3.4KB symbol lists
  1025. // from cluster scoring + edge-source lines, dwarfing the per-file
  1026. // body cap. Show top names by frequency, with a "+N more" tail.
  1027. const symbolCounts = new Map<string, number>();
  1028. for (const s of allSymbols) {
  1029. symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
  1030. }
  1031. const sortedSymbols = [...symbolCounts.entries()]
  1032. .sort((a, b) => b[1] - a[1])
  1033. .map(([name]) => name);
  1034. const headerCap = budget.maxSymbolsInFileHeader;
  1035. const headerSymbols = sortedSymbols.slice(0, headerCap);
  1036. const omittedCount = sortedSymbols.length - headerSymbols.length;
  1037. const headerSuffix = omittedCount > 0
  1038. ? `${headerSymbols.join(', ')}, +${omittedCount} more`
  1039. : headerSymbols.join(', ');
  1040. const fileHeader = `#### ${filePath} — ${headerSuffix}`;
  1041. // Respect the total output cap on a file-by-file basis.
  1042. if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
  1043. const remaining = budget.maxOutputChars - totalChars - 200;
  1044. if (remaining < 500) break;
  1045. const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
  1046. lines.push(fileHeader);
  1047. lines.push('');
  1048. lines.push('```' + lang);
  1049. lines.push(trimmed);
  1050. lines.push('```');
  1051. lines.push('');
  1052. totalChars += trimmed.length + 200;
  1053. filesIncluded++;
  1054. anyFileTrimmed = true;
  1055. break;
  1056. }
  1057. lines.push(fileHeader);
  1058. lines.push('');
  1059. lines.push('```' + lang);
  1060. lines.push(fileSection);
  1061. lines.push('```');
  1062. lines.push('');
  1063. totalChars += fileSection.length + 200;
  1064. filesIncluded++;
  1065. }
  1066. // Add remaining files as references (from both relevant and peripheral files).
  1067. // Small projects (per budget) skip this — the relevant story already fits
  1068. // in the source section, and a trailing pointer list is pure overhead.
  1069. if (budget.includeAdditionalFiles) {
  1070. const remainingRelevant = sortedFiles.slice(filesIncluded);
  1071. const peripheralFiles = [...fileGroups.entries()]
  1072. .filter(([, group]) => group.score < 3)
  1073. .sort((a, b) => b[1].score - a[1].score);
  1074. const remainingFiles = [...remainingRelevant, ...peripheralFiles];
  1075. if (remainingFiles.length > 0) {
  1076. lines.push('### Additional relevant files (not shown)');
  1077. lines.push('');
  1078. for (const [filePath, group] of remainingFiles.slice(0, 10)) {
  1079. const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  1080. lines.push(`- ${filePath}: ${symbols}`);
  1081. }
  1082. if (remainingFiles.length > 10) {
  1083. lines.push(`- ... and ${remainingFiles.length - 10} more files`);
  1084. }
  1085. }
  1086. }
  1087. // Add completeness signal so agents know they don't need to re-read these files.
  1088. // On small projects the budget gates this off — but if we actually had to
  1089. // trim or drop clusters, surface a brief note so the agent knows it can
  1090. // still Read for more detail.
  1091. if (budget.includeCompletenessSignal) {
  1092. lines.push('');
  1093. lines.push('---');
  1094. lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`);
  1095. } else if (anyFileTrimmed) {
  1096. lines.push('');
  1097. lines.push(`> Some file sections were trimmed for size. Use \`codegraph_node\` or Read for the full source if needed.`);
  1098. }
  1099. // Add explore budget note based on project size
  1100. if (budget.includeBudgetNote) {
  1101. try {
  1102. const stats = cg.getStats();
  1103. const callBudget = getExploreBudget(stats.fileCount);
  1104. lines.push('');
  1105. lines.push(`> **Explore budget: ${callBudget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${callBudget} calls — do NOT make additional explore calls beyond this budget.`);
  1106. } catch {
  1107. // Stats unavailable — skip budget note
  1108. }
  1109. }
  1110. return this.textResult(lines.join('\n'));
  1111. }
  1112. /**
  1113. * Handle codegraph_node
  1114. */
  1115. private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
  1116. const symbol = this.validateString(args.symbol, 'symbol');
  1117. if (typeof symbol !== 'string') return symbol;
  1118. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1119. // Default to false to minimize context usage
  1120. const includeCode = args.includeCode === true;
  1121. const match = this.findSymbol(cg, symbol);
  1122. if (!match) {
  1123. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  1124. }
  1125. let code: string | null = null;
  1126. if (includeCode) {
  1127. code = await cg.getCode(match.node.id);
  1128. }
  1129. const formatted = this.formatNodeDetails(match.node, code) + match.note;
  1130. return this.textResult(this.truncateOutput(formatted));
  1131. }
  1132. /**
  1133. * Handle codegraph_status
  1134. */
  1135. private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
  1136. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1137. const stats = cg.getStats();
  1138. const lines: string[] = [
  1139. '## CodeGraph Status',
  1140. '',
  1141. `**Files indexed:** ${stats.fileCount}`,
  1142. `**Total nodes:** ${stats.nodeCount}`,
  1143. `**Total edges:** ${stats.edgeCount}`,
  1144. `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
  1145. ];
  1146. // Surface the active SQLite backend. Without this, users on the
  1147. // silent WASM fallback (better-sqlite3 install failed) see "slow"
  1148. // indexing and DB-lock errors with no signal of why.
  1149. const backend = cg.getBackend();
  1150. if (backend === 'native') {
  1151. lines.push(`**Backend:** native (better-sqlite3)`);
  1152. } else {
  1153. lines.push(
  1154. `**Backend:** ⚠ wasm (better-sqlite3 unavailable) — ` +
  1155. `5-10x slower than native. Fix: ${WASM_FALLBACK_FIX_RECIPE}`
  1156. );
  1157. }
  1158. lines.push('', '### Nodes by Kind:');
  1159. for (const [kind, count] of Object.entries(stats.nodesByKind)) {
  1160. if ((count as number) > 0) {
  1161. lines.push(`- ${kind}: ${count}`);
  1162. }
  1163. }
  1164. lines.push('', '### Languages:');
  1165. for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
  1166. if ((count as number) > 0) {
  1167. lines.push(`- ${lang}: ${count}`);
  1168. }
  1169. }
  1170. return this.textResult(lines.join('\n'));
  1171. }
  1172. /**
  1173. * Handle codegraph_files - get project file structure from the index
  1174. */
  1175. private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
  1176. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1177. const pathFilter = args.path as string | undefined;
  1178. const pattern = args.pattern as string | undefined;
  1179. const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
  1180. const includeMetadata = args.includeMetadata !== false;
  1181. const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
  1182. // Get all files from the index
  1183. const allFiles = cg.getFiles();
  1184. if (allFiles.length === 0) {
  1185. return this.textResult('No files indexed. Run `codegraph index` first.');
  1186. }
  1187. // Filter by path prefix
  1188. let files = pathFilter
  1189. ? allFiles.filter(f => f.path.startsWith(pathFilter) || f.path.startsWith('./' + pathFilter))
  1190. : allFiles;
  1191. // Filter by glob pattern
  1192. if (pattern) {
  1193. const regex = this.globToRegex(pattern);
  1194. files = files.filter(f => regex.test(f.path));
  1195. }
  1196. if (files.length === 0) {
  1197. return this.textResult(`No files found matching the criteria.`);
  1198. }
  1199. // Format output
  1200. let output: string;
  1201. switch (format) {
  1202. case 'flat':
  1203. output = this.formatFilesFlat(files, includeMetadata);
  1204. break;
  1205. case 'grouped':
  1206. output = this.formatFilesGrouped(files, includeMetadata);
  1207. break;
  1208. case 'tree':
  1209. default:
  1210. output = this.formatFilesTree(files, includeMetadata, maxDepth);
  1211. break;
  1212. }
  1213. return this.textResult(this.truncateOutput(output));
  1214. }
  1215. /**
  1216. * Convert glob pattern to regex
  1217. */
  1218. private globToRegex(pattern: string): RegExp {
  1219. const escaped = pattern
  1220. .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
  1221. .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
  1222. .replace(/\*/g, '[^/]*') // * matches anything except /
  1223. .replace(/\?/g, '[^/]') // ? matches single char except /
  1224. .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
  1225. return new RegExp(escaped);
  1226. }
  1227. /**
  1228. * Format files as a flat list
  1229. */
  1230. private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1231. const lines: string[] = [`## Files (${files.length})`, ''];
  1232. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  1233. if (includeMetadata) {
  1234. lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
  1235. } else {
  1236. lines.push(`- ${file.path}`);
  1237. }
  1238. }
  1239. return lines.join('\n');
  1240. }
  1241. /**
  1242. * Format files grouped by language
  1243. */
  1244. private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1245. const byLang = new Map<string, typeof files>();
  1246. for (const file of files) {
  1247. const existing = byLang.get(file.language) || [];
  1248. existing.push(file);
  1249. byLang.set(file.language, existing);
  1250. }
  1251. const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
  1252. // Sort languages by file count (descending)
  1253. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  1254. for (const [lang, langFiles] of sortedLangs) {
  1255. lines.push(`### ${lang} (${langFiles.length})`);
  1256. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  1257. if (includeMetadata) {
  1258. lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
  1259. } else {
  1260. lines.push(`- ${file.path}`);
  1261. }
  1262. }
  1263. lines.push('');
  1264. }
  1265. return lines.join('\n');
  1266. }
  1267. /**
  1268. * Format files as a tree structure
  1269. */
  1270. private formatFilesTree(
  1271. files: { path: string; language: string; nodeCount: number }[],
  1272. includeMetadata: boolean,
  1273. maxDepth?: number
  1274. ): string {
  1275. // Build tree structure
  1276. interface TreeNode {
  1277. name: string;
  1278. children: Map<string, TreeNode>;
  1279. file?: { language: string; nodeCount: number };
  1280. }
  1281. const root: TreeNode = { name: '', children: new Map() };
  1282. for (const file of files) {
  1283. const parts = file.path.split('/');
  1284. let current = root;
  1285. for (let i = 0; i < parts.length; i++) {
  1286. const part = parts[i];
  1287. if (!part) continue;
  1288. if (!current.children.has(part)) {
  1289. current.children.set(part, { name: part, children: new Map() });
  1290. }
  1291. current = current.children.get(part)!;
  1292. // If this is the last part, it's a file
  1293. if (i === parts.length - 1) {
  1294. current.file = { language: file.language, nodeCount: file.nodeCount };
  1295. }
  1296. }
  1297. }
  1298. // Render tree
  1299. const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
  1300. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  1301. if (maxDepth !== undefined && depth > maxDepth) return;
  1302. const connector = isLast ? '└── ' : '├── ';
  1303. const childPrefix = isLast ? ' ' : '│ ';
  1304. if (node.name) {
  1305. let line = prefix + connector + node.name;
  1306. if (node.file && includeMetadata) {
  1307. line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
  1308. }
  1309. lines.push(line);
  1310. }
  1311. const children = [...node.children.values()];
  1312. // Sort: directories first, then files, both alphabetically
  1313. children.sort((a, b) => {
  1314. const aIsDir = a.children.size > 0 && !a.file;
  1315. const bIsDir = b.children.size > 0 && !b.file;
  1316. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  1317. return a.name.localeCompare(b.name);
  1318. });
  1319. for (let i = 0; i < children.length; i++) {
  1320. const child = children[i]!;
  1321. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  1322. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  1323. }
  1324. };
  1325. renderNode(root, '', true, 0);
  1326. return lines.join('\n');
  1327. }
  1328. // =========================================================================
  1329. // Symbol resolution helpers
  1330. // =========================================================================
  1331. /**
  1332. * Find a symbol by name, handling disambiguation when multiple matches exist.
  1333. * Returns the best match and a note about alternatives if any.
  1334. */
  1335. /**
  1336. * Check if a node matches a symbol query.
  1337. *
  1338. * Accepts simple names (`run`) and three flavors of qualifier:
  1339. * - dotted `Session.request` (TS/JS/Python)
  1340. * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
  1341. * - slash `configurator/stage_apply` (path-ish)
  1342. *
  1343. * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
  1344. * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
  1345. * the canonical `crate::module::symbol` form resolves.
  1346. *
  1347. * Resolution order, last part must always equal `node.name`:
  1348. * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
  1349. * where the extractor builds the qualified name from the AST stack)
  1350. * 2. File-path containment (handles file-derived modules in Rust/
  1351. * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
  1352. */
  1353. private matchesSymbol(node: Node, symbol: string): boolean {
  1354. // Simple name match
  1355. if (node.name === symbol) return true;
  1356. // File basename match (e.g., "product-card" matches "product-card.liquid")
  1357. if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
  1358. // Qualified-name lookups: split on any supported separator. `\w` keeps
  1359. // identifier chars (incl. `_`) intact; everything else is treated as
  1360. // a separator we tolerate.
  1361. if (!/[.\/]|::/.test(symbol)) return false;
  1362. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  1363. if (parts.length < 2) return false;
  1364. const lastPart = parts[parts.length - 1]!;
  1365. if (node.name !== lastPart) return false;
  1366. // Stage 1: qualified-name suffix match. The extractor joins the
  1367. // semantic hierarchy with `::`, so `Session.request` and
  1368. // `Session::request` both become `Session::request` here.
  1369. const colonSuffix = parts.join('::');
  1370. if (node.qualifiedName.includes(colonSuffix)) return true;
  1371. // Stage 2: file-path containment. Rust modules and Python packages
  1372. // are not in `qualifiedName` — they're encoded in the file path. So
  1373. // `stage_apply::run` matches a `run` in any file whose path
  1374. // contains a `stage_apply` segment (with or without an extension).
  1375. //
  1376. // Filter out Rust path prefixes that have no file-system equivalent.
  1377. const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
  1378. if (containerHints.length === 0) return false;
  1379. const segments = node.filePath.split('/').filter((s) => s.length > 0);
  1380. return containerHints.every((hint) =>
  1381. segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
  1382. );
  1383. }
  1384. private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
  1385. // Use higher limit for qualified lookups (e.g., "Session.request",
  1386. // "stage_apply::run") since the target may rank lower in FTS when
  1387. // there are many partial matches across the qualifier parts.
  1388. const isQualified = /[.\/]|::/.test(symbol);
  1389. const limit = isQualified ? 50 : 10;
  1390. let results = cg.searchNodes(symbol, { limit });
  1391. // FTS strips colons as a special char, so `stage_apply::run` searches
  1392. // for the literal `stage_applyrun` and finds nothing. Re-search by
  1393. // the bare last part and let `matchesSymbol` filter by qualifier.
  1394. if (isQualified && results.length === 0) {
  1395. const tail = lastQualifierPart(symbol);
  1396. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
  1397. }
  1398. if (results.length === 0 || !results[0]) {
  1399. return null;
  1400. }
  1401. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  1402. if (exactMatches.length === 1) {
  1403. return { node: exactMatches[0]!.node, note: '' };
  1404. }
  1405. if (exactMatches.length > 1) {
  1406. // Multiple exact matches - pick first, note the others
  1407. const picked = exactMatches[0]!.node;
  1408. const others = exactMatches.slice(1).map(r =>
  1409. `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
  1410. );
  1411. const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
  1412. return { node: picked, note };
  1413. }
  1414. // No exact match. For qualified lookups, don't silently fall back
  1415. // to a fuzzy result — the user typed a specific qualifier, and
  1416. // resolving `stage_apply::nonexistent_fn` to the unrelated
  1417. // `stage_apply.rs` file would be actively misleading (#173).
  1418. if (isQualified) return null;
  1419. return { node: results[0]!.node, note: '' };
  1420. }
  1421. /**
  1422. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  1423. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  1424. */
  1425. private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  1426. let results = cg.searchNodes(symbol, { limit: 50 });
  1427. // Mirror the fallback in `findSymbol` for qualified queries — FTS
  1428. // strips colons, so a module-qualified lookup needs a second pass
  1429. // by the bare last part.
  1430. if (results.length === 0 && /[.\/]|::/.test(symbol)) {
  1431. const tail = lastQualifierPart(symbol);
  1432. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
  1433. }
  1434. if (results.length === 0) {
  1435. return { nodes: [], note: '' };
  1436. }
  1437. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  1438. if (exactMatches.length <= 1) {
  1439. const node = exactMatches[0]?.node ?? results[0]!.node;
  1440. return { nodes: [node], note: '' };
  1441. }
  1442. const locations = exactMatches.map(r =>
  1443. `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
  1444. );
  1445. const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
  1446. return { nodes: exactMatches.map(r => r.node), note };
  1447. }
  1448. /**
  1449. * Truncate output if it exceeds the maximum length
  1450. */
  1451. private truncateOutput(text: string): string {
  1452. if (text.length <= MAX_OUTPUT_LENGTH) return text;
  1453. const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
  1454. const lastNewline = truncated.lastIndexOf('\n');
  1455. const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
  1456. return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
  1457. }
  1458. // =========================================================================
  1459. // Formatting helpers (compact by default to reduce context usage)
  1460. // =========================================================================
  1461. private formatSearchResults(results: SearchResult[]): string {
  1462. const lines: string[] = [`## Search Results (${results.length} found)`, ''];
  1463. for (const result of results) {
  1464. const { node } = result;
  1465. const location = node.startLine ? `:${node.startLine}` : '';
  1466. // Compact format: one line per result with key info
  1467. lines.push(`### ${node.name} (${node.kind})`);
  1468. lines.push(`${node.filePath}${location}`);
  1469. if (node.signature) lines.push(`\`${node.signature}\``);
  1470. lines.push('');
  1471. }
  1472. return lines.join('\n');
  1473. }
  1474. private formatNodeList(nodes: Node[], title: string): string {
  1475. const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
  1476. for (const node of nodes) {
  1477. const location = node.startLine ? `:${node.startLine}` : '';
  1478. // Compact: just name, kind, location
  1479. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
  1480. }
  1481. return lines.join('\n');
  1482. }
  1483. private formatImpact(symbol: string, impact: Subgraph): string {
  1484. const nodeCount = impact.nodes.size;
  1485. // Compact format: just list affected symbols grouped by file
  1486. const lines: string[] = [
  1487. `## Impact: "${symbol}" affects ${nodeCount} symbols`,
  1488. '',
  1489. ];
  1490. // Group by file
  1491. const byFile = new Map<string, Node[]>();
  1492. for (const node of impact.nodes.values()) {
  1493. const existing = byFile.get(node.filePath) || [];
  1494. existing.push(node);
  1495. byFile.set(node.filePath, existing);
  1496. }
  1497. for (const [file, nodes] of byFile) {
  1498. lines.push(`**${file}:**`);
  1499. // Compact: inline list
  1500. const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  1501. lines.push(nodeList);
  1502. lines.push('');
  1503. }
  1504. return lines.join('\n');
  1505. }
  1506. private formatNodeDetails(node: Node, code: string | null): string {
  1507. const location = node.startLine ? `:${node.startLine}` : '';
  1508. const lines: string[] = [
  1509. `## ${node.name} (${node.kind})`,
  1510. '',
  1511. `**Location:** ${node.filePath}${location}`,
  1512. ];
  1513. if (node.signature) {
  1514. lines.push(`**Signature:** \`${node.signature}\``);
  1515. }
  1516. // Only include docstring if it's short and useful
  1517. if (node.docstring && node.docstring.length < 200) {
  1518. lines.push('', node.docstring);
  1519. }
  1520. if (code) {
  1521. lines.push('', '```' + node.language, code, '```');
  1522. }
  1523. return lines.join('\n');
  1524. }
  1525. private formatTaskContext(context: TaskContext): string {
  1526. return context.summary || 'No context found';
  1527. }
  1528. private textResult(text: string): ToolResult {
  1529. return {
  1530. content: [{ type: 'text', text }],
  1531. };
  1532. }
  1533. private errorResult(message: string): ToolResult {
  1534. return {
  1535. content: [{ type: 'text', text: `Error: ${message}` }],
  1536. isError: true,
  1537. };
  1538. }
  1539. }