tools.ts 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352
  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. /** Maximum output length to prevent context bloat (characters) */
  14. const MAX_OUTPUT_LENGTH = 15000;
  15. /**
  16. * Calculate the recommended number of codegraph_explore calls based on project size.
  17. * Larger codebases need more exploration calls to cover their surface area,
  18. * but smaller ones should use fewer to avoid unnecessary overhead.
  19. */
  20. export function getExploreBudget(fileCount: number): number {
  21. if (fileCount < 1000) return 2;
  22. if (fileCount < 5000) return 3;
  23. if (fileCount < 15000) return 4;
  24. if (fileCount < 25000) return 5;
  25. return 6;
  26. }
  27. /**
  28. * Mark a Claude session as having consulted MCP tools.
  29. * This enables Grep/Glob/Bash commands that would otherwise be blocked.
  30. */
  31. function markSessionConsulted(sessionId: string): void {
  32. try {
  33. const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
  34. const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
  35. writeFileSync(markerPath, new Date().toISOString(), 'utf8');
  36. } catch {
  37. // Silently fail - don't break MCP on marker write failure
  38. }
  39. }
  40. /**
  41. * MCP Tool definition
  42. */
  43. export interface ToolDefinition {
  44. name: string;
  45. description: string;
  46. inputSchema: {
  47. type: 'object';
  48. properties: Record<string, PropertySchema>;
  49. required?: string[];
  50. };
  51. }
  52. interface PropertySchema {
  53. type: string;
  54. description: string;
  55. enum?: string[];
  56. default?: unknown;
  57. }
  58. /**
  59. * Tool execution result
  60. */
  61. export interface ToolResult {
  62. content: Array<{
  63. type: 'text';
  64. text: string;
  65. }>;
  66. isError?: boolean;
  67. }
  68. /**
  69. * Common projectPath property for cross-project queries
  70. */
  71. const projectPathProperty: PropertySchema = {
  72. type: 'string',
  73. description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
  74. };
  75. /**
  76. * All CodeGraph MCP tools
  77. *
  78. * Designed for minimal context usage - use codegraph_context as the primary tool,
  79. * and only use other tools for targeted follow-up queries.
  80. *
  81. * All tools support cross-project queries via the optional `projectPath` parameter.
  82. */
  83. export const tools: ToolDefinition[] = [
  84. {
  85. name: 'codegraph_search',
  86. description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
  87. inputSchema: {
  88. type: 'object',
  89. properties: {
  90. query: {
  91. type: 'string',
  92. description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
  93. },
  94. kind: {
  95. type: 'string',
  96. description: 'Filter by node kind',
  97. enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
  98. },
  99. limit: {
  100. type: 'number',
  101. description: 'Maximum results (default: 10)',
  102. default: 10,
  103. },
  104. projectPath: projectPathProperty,
  105. },
  106. required: ['query'],
  107. },
  108. },
  109. {
  110. name: 'codegraph_context',
  111. 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.',
  112. inputSchema: {
  113. type: 'object',
  114. properties: {
  115. task: {
  116. type: 'string',
  117. description: 'Description of the task, bug, or feature to build context for',
  118. },
  119. maxNodes: {
  120. type: 'number',
  121. description: 'Maximum symbols to include (default: 20)',
  122. default: 20,
  123. },
  124. includeCode: {
  125. type: 'boolean',
  126. description: 'Include code snippets for key symbols (default: true)',
  127. default: true,
  128. },
  129. projectPath: projectPathProperty,
  130. },
  131. required: ['task'],
  132. },
  133. },
  134. {
  135. name: 'codegraph_callers',
  136. description: 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.',
  137. inputSchema: {
  138. type: 'object',
  139. properties: {
  140. symbol: {
  141. type: 'string',
  142. description: 'Name of the function, method, or class to find callers for',
  143. },
  144. limit: {
  145. type: 'number',
  146. description: 'Maximum number of callers to return (default: 20)',
  147. default: 20,
  148. },
  149. projectPath: projectPathProperty,
  150. },
  151. required: ['symbol'],
  152. },
  153. },
  154. {
  155. name: 'codegraph_callees',
  156. description: 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.',
  157. inputSchema: {
  158. type: 'object',
  159. properties: {
  160. symbol: {
  161. type: 'string',
  162. description: 'Name of the function, method, or class to find callees for',
  163. },
  164. limit: {
  165. type: 'number',
  166. description: 'Maximum number of callees to return (default: 20)',
  167. default: 20,
  168. },
  169. projectPath: projectPathProperty,
  170. },
  171. required: ['symbol'],
  172. },
  173. },
  174. {
  175. name: 'codegraph_impact',
  176. description: 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.',
  177. inputSchema: {
  178. type: 'object',
  179. properties: {
  180. symbol: {
  181. type: 'string',
  182. description: 'Name of the symbol to analyze impact for',
  183. },
  184. depth: {
  185. type: 'number',
  186. description: 'How many levels of dependencies to traverse (default: 2)',
  187. default: 2,
  188. },
  189. projectPath: projectPathProperty,
  190. },
  191. required: ['symbol'],
  192. },
  193. },
  194. {
  195. name: 'codegraph_node',
  196. 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.',
  197. inputSchema: {
  198. type: 'object',
  199. properties: {
  200. symbol: {
  201. type: 'string',
  202. description: 'Name of the symbol to get details for',
  203. },
  204. includeCode: {
  205. type: 'boolean',
  206. description: 'Include full source code (default: false to minimize context)',
  207. default: false,
  208. },
  209. projectPath: projectPathProperty,
  210. },
  211. required: ['symbol'],
  212. },
  213. },
  214. {
  215. name: 'codegraph_explore',
  216. description: 'Deep exploration tool — returns comprehensive context for a topic in a SINGLE call. Groups all relevant source code by file (contiguous sections, not snippets), includes a relationship map, and uses deeper graph traversal. Designed to replace multiple codegraph_node + file Read calls. Use this instead of codegraph_context when you need thorough understanding.',
  217. inputSchema: {
  218. type: 'object',
  219. properties: {
  220. query: {
  221. type: 'string',
  222. description: 'What you want to understand (e.g., "undo redo system", "authentication flow", "how routing works")',
  223. },
  224. maxFiles: {
  225. type: 'number',
  226. description: 'Maximum number of files to include source code from (default: 12)',
  227. default: 12,
  228. },
  229. projectPath: projectPathProperty,
  230. },
  231. required: ['query'],
  232. },
  233. },
  234. {
  235. name: 'codegraph_status',
  236. description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.',
  237. inputSchema: {
  238. type: 'object',
  239. properties: {
  240. projectPath: projectPathProperty,
  241. },
  242. },
  243. },
  244. {
  245. name: 'codegraph_files',
  246. 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.',
  247. inputSchema: {
  248. type: 'object',
  249. properties: {
  250. path: {
  251. type: 'string',
  252. description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
  253. },
  254. pattern: {
  255. type: 'string',
  256. description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
  257. },
  258. format: {
  259. type: 'string',
  260. description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
  261. enum: ['tree', 'flat', 'grouped'],
  262. default: 'tree',
  263. },
  264. includeMetadata: {
  265. type: 'boolean',
  266. description: 'Include file metadata like language and symbol count (default: true)',
  267. default: true,
  268. },
  269. maxDepth: {
  270. type: 'number',
  271. description: 'Maximum directory depth to show (default: unlimited)',
  272. },
  273. projectPath: projectPathProperty,
  274. },
  275. },
  276. },
  277. ];
  278. /**
  279. * Tool handler that executes tools against a CodeGraph instance
  280. *
  281. * Supports cross-project queries via the projectPath parameter.
  282. * Other projects are opened on-demand and cached for performance.
  283. */
  284. export class ToolHandler {
  285. // Cache of opened CodeGraph instances for cross-project queries
  286. private projectCache: Map<string, CodeGraph> = new Map();
  287. constructor(private cg: CodeGraph | null) {}
  288. /**
  289. * Update the default CodeGraph instance (e.g. after lazy initialization)
  290. */
  291. setDefaultCodeGraph(cg: CodeGraph): void {
  292. this.cg = cg;
  293. }
  294. /**
  295. * Whether a default CodeGraph instance is available
  296. */
  297. hasDefaultCodeGraph(): boolean {
  298. return this.cg !== null;
  299. }
  300. /**
  301. * Get tool definitions with dynamic descriptions based on project size.
  302. * The codegraph_explore tool description includes a budget recommendation
  303. * scaled to the number of indexed files.
  304. */
  305. getTools(): ToolDefinition[] {
  306. if (!this.cg) return tools;
  307. try {
  308. const stats = this.cg.getStats();
  309. const budget = getExploreBudget(stats.fileCount);
  310. return tools.map(tool => {
  311. if (tool.name === 'codegraph_explore') {
  312. return {
  313. ...tool,
  314. description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
  315. };
  316. }
  317. return tool;
  318. });
  319. } catch {
  320. return tools;
  321. }
  322. }
  323. /**
  324. * Get CodeGraph instance for a project
  325. *
  326. * If projectPath is provided, opens that project's CodeGraph (cached).
  327. * Otherwise returns the default CodeGraph instance.
  328. *
  329. * Walks up parent directories to find the nearest .codegraph/ folder,
  330. * similar to how git finds .git/ directories.
  331. */
  332. private getCodeGraph(projectPath?: string): CodeGraph {
  333. if (!projectPath) {
  334. if (!this.cg) {
  335. throw new Error('CodeGraph not initialized for this project. Run \'codegraph init\' first.');
  336. }
  337. return this.cg;
  338. }
  339. // Check cache first (using original path as key)
  340. if (this.projectCache.has(projectPath)) {
  341. return this.projectCache.get(projectPath)!;
  342. }
  343. // Walk up parent directories to find nearest .codegraph/
  344. const resolvedRoot = findNearestCodeGraphRoot(projectPath);
  345. if (!resolvedRoot) {
  346. throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
  347. }
  348. // Check if we already have this resolved root cached (different path, same project)
  349. if (this.projectCache.has(resolvedRoot)) {
  350. const cg = this.projectCache.get(resolvedRoot)!;
  351. // Cache under original path too for faster future lookups
  352. this.projectCache.set(projectPath, cg);
  353. return cg;
  354. }
  355. // Open and cache under both paths
  356. const cg = CodeGraph.openSync(resolvedRoot);
  357. this.projectCache.set(resolvedRoot, cg);
  358. if (projectPath !== resolvedRoot) {
  359. this.projectCache.set(projectPath, cg);
  360. }
  361. return cg;
  362. }
  363. /**
  364. * Close all cached project connections
  365. */
  366. closeAll(): void {
  367. for (const cg of this.projectCache.values()) {
  368. cg.close();
  369. }
  370. this.projectCache.clear();
  371. }
  372. /**
  373. * Validate that a value is a non-empty string
  374. */
  375. private validateString(value: unknown, name: string): string | ToolResult {
  376. if (typeof value !== 'string' || value.length === 0) {
  377. return this.errorResult(`${name} must be a non-empty string`);
  378. }
  379. return value;
  380. }
  381. /**
  382. * Execute a tool by name
  383. */
  384. async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  385. try {
  386. switch (toolName) {
  387. case 'codegraph_search':
  388. return await this.handleSearch(args);
  389. case 'codegraph_context':
  390. return await this.handleContext(args);
  391. case 'codegraph_callers':
  392. return await this.handleCallers(args);
  393. case 'codegraph_callees':
  394. return await this.handleCallees(args);
  395. case 'codegraph_impact':
  396. return await this.handleImpact(args);
  397. case 'codegraph_explore':
  398. return await this.handleExplore(args);
  399. case 'codegraph_node':
  400. return await this.handleNode(args);
  401. case 'codegraph_status':
  402. return await this.handleStatus(args);
  403. case 'codegraph_files':
  404. return await this.handleFiles(args);
  405. default:
  406. return this.errorResult(`Unknown tool: ${toolName}`);
  407. }
  408. } catch (err) {
  409. return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
  410. }
  411. }
  412. /**
  413. * Handle codegraph_search
  414. */
  415. private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
  416. const query = this.validateString(args.query, 'query');
  417. if (typeof query !== 'string') return query;
  418. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  419. const kind = args.kind as string | undefined;
  420. const rawLimit = Number(args.limit) || 10;
  421. const limit = clamp(rawLimit, 1, 100);
  422. const results = cg.searchNodes(query, {
  423. limit,
  424. kinds: kind ? [kind as NodeKind] : undefined,
  425. });
  426. if (results.length === 0) {
  427. return this.textResult(`No results found for "${query}"`);
  428. }
  429. const formatted = this.formatSearchResults(results);
  430. return this.textResult(this.truncateOutput(formatted));
  431. }
  432. /**
  433. * Handle codegraph_context
  434. */
  435. private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
  436. const task = this.validateString(args.task, 'task');
  437. if (typeof task !== 'string') return task;
  438. // Mark session as consulted (enables Grep/Glob/Bash)
  439. const sessionId = process.env.CLAUDE_SESSION_ID;
  440. if (sessionId) {
  441. markSessionConsulted(sessionId);
  442. }
  443. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  444. const maxNodes = (args.maxNodes as number) || 20;
  445. const includeCode = args.includeCode !== false;
  446. const context = await cg.buildContext(task, {
  447. maxNodes,
  448. includeCode,
  449. format: 'markdown',
  450. });
  451. // Detect if this looks like a feature request (vs bug fix or exploration)
  452. const isFeatureQuery = this.looksLikeFeatureRequest(task);
  453. const reminder = isFeatureQuery
  454. ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
  455. : '';
  456. // buildContext returns string when format is 'markdown'
  457. if (typeof context === 'string') {
  458. return this.textResult(context + reminder);
  459. }
  460. // If it returns TaskContext, format it
  461. return this.textResult(this.formatTaskContext(context) + reminder);
  462. }
  463. /**
  464. * Heuristic to detect if a query looks like a feature request
  465. */
  466. private looksLikeFeatureRequest(task: string): boolean {
  467. const featureKeywords = [
  468. 'add', 'create', 'implement', 'build', 'enable', 'allow',
  469. 'new feature', 'support for', 'ability to', 'want to',
  470. 'should be able', 'need to add', 'swap', 'edit', 'modify'
  471. ];
  472. const bugKeywords = [
  473. 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
  474. 'not working', 'fails', 'undefined', 'null'
  475. ];
  476. const explorationKeywords = [
  477. 'how does', 'where is', 'what is', 'find', 'show me',
  478. 'explain', 'understand', 'explore'
  479. ];
  480. const lowerTask = task.toLowerCase();
  481. // If it's clearly a bug or exploration, not a feature
  482. if (bugKeywords.some(k => lowerTask.includes(k))) return false;
  483. if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
  484. // If it matches feature keywords, it's likely a feature request
  485. return featureKeywords.some(k => lowerTask.includes(k));
  486. }
  487. /**
  488. * Handle codegraph_callers
  489. */
  490. private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
  491. const symbol = this.validateString(args.symbol, 'symbol');
  492. if (typeof symbol !== 'string') return symbol;
  493. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  494. const limit = clamp((args.limit as number) || 20, 1, 100);
  495. const allMatches = this.findAllSymbols(cg, symbol);
  496. if (allMatches.nodes.length === 0) {
  497. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  498. }
  499. // Aggregate callers across all matching symbols
  500. const seen = new Set<string>();
  501. const allCallers: Node[] = [];
  502. for (const node of allMatches.nodes) {
  503. for (const c of cg.getCallers(node.id)) {
  504. if (!seen.has(c.node.id)) {
  505. seen.add(c.node.id);
  506. allCallers.push(c.node);
  507. }
  508. }
  509. }
  510. if (allCallers.length === 0) {
  511. return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
  512. }
  513. const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
  514. return this.textResult(this.truncateOutput(formatted));
  515. }
  516. /**
  517. * Handle codegraph_callees
  518. */
  519. private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
  520. const symbol = this.validateString(args.symbol, 'symbol');
  521. if (typeof symbol !== 'string') return symbol;
  522. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  523. const limit = clamp((args.limit as number) || 20, 1, 100);
  524. const allMatches = this.findAllSymbols(cg, symbol);
  525. if (allMatches.nodes.length === 0) {
  526. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  527. }
  528. // Aggregate callees across all matching symbols
  529. const seen = new Set<string>();
  530. const allCallees: Node[] = [];
  531. for (const node of allMatches.nodes) {
  532. for (const c of cg.getCallees(node.id)) {
  533. if (!seen.has(c.node.id)) {
  534. seen.add(c.node.id);
  535. allCallees.push(c.node);
  536. }
  537. }
  538. }
  539. if (allCallees.length === 0) {
  540. return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
  541. }
  542. const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
  543. return this.textResult(this.truncateOutput(formatted));
  544. }
  545. /**
  546. * Handle codegraph_impact
  547. */
  548. private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
  549. const symbol = this.validateString(args.symbol, 'symbol');
  550. if (typeof symbol !== 'string') return symbol;
  551. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  552. const depth = clamp((args.depth as number) || 2, 1, 10);
  553. const allMatches = this.findAllSymbols(cg, symbol);
  554. if (allMatches.nodes.length === 0) {
  555. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  556. }
  557. // Aggregate impact across all matching symbols
  558. const mergedNodes = new Map<string, Node>();
  559. const mergedEdges: Edge[] = [];
  560. const seenEdges = new Set<string>();
  561. for (const node of allMatches.nodes) {
  562. const impact = cg.getImpactRadius(node.id, depth);
  563. for (const [id, n] of impact.nodes) {
  564. mergedNodes.set(id, n);
  565. }
  566. for (const e of impact.edges) {
  567. const key = `${e.source}->${e.target}:${e.kind}`;
  568. if (!seenEdges.has(key)) {
  569. seenEdges.add(key);
  570. mergedEdges.push(e);
  571. }
  572. }
  573. }
  574. const mergedImpact = {
  575. nodes: mergedNodes,
  576. edges: mergedEdges,
  577. roots: allMatches.nodes.map(n => n.id),
  578. };
  579. const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
  580. return this.textResult(this.truncateOutput(formatted));
  581. }
  582. /** Maximum output for explore tool — sized to stay under MCP client token limits (~10k tokens) */
  583. private static readonly EXPLORE_MAX_OUTPUT = 35000;
  584. /**
  585. * Handle codegraph_explore — deep exploration in a single call
  586. *
  587. * Strategy: find relevant symbols via graph traversal, group by file,
  588. * then read contiguous file sections covering all symbols per file.
  589. * This replaces multiple codegraph_node + Read calls.
  590. */
  591. private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
  592. const query = this.validateString(args.query, 'query');
  593. if (typeof query !== 'string') return query;
  594. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  595. const maxFiles = clamp((args.maxFiles as number) || 12, 1, 20);
  596. const projectRoot = cg.getProjectRoot();
  597. // Step 1: Find relevant context with generous parameters
  598. const subgraph = await cg.findRelevantContext(query, {
  599. searchLimit: 8,
  600. traversalDepth: 3,
  601. maxNodes: 80,
  602. minScore: 0.2,
  603. });
  604. if (subgraph.nodes.size === 0) {
  605. return this.textResult(`No relevant code found for "${query}"`);
  606. }
  607. // Step 2: Group nodes by file, score by relevance
  608. const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
  609. const entryNodeIds = new Set(subgraph.roots);
  610. // Build a set of nodes directly connected to entry points (depth 1)
  611. const connectedToEntry = new Set<string>();
  612. for (const edge of subgraph.edges) {
  613. if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
  614. if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
  615. }
  616. for (const node of subgraph.nodes.values()) {
  617. // Skip import/export nodes — they add noise without information
  618. if (node.kind === 'import' || node.kind === 'export') continue;
  619. const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
  620. group.nodes.push(node);
  621. // Score: entry point nodes worth 10, directly connected worth 3, others worth 1
  622. if (entryNodeIds.has(node.id)) {
  623. group.score += 10;
  624. } else if (connectedToEntry.has(node.id)) {
  625. group.score += 3;
  626. } else {
  627. group.score += 1;
  628. }
  629. fileGroups.set(node.filePath, group);
  630. }
  631. // Only include files that have entry points or nodes directly connected to entry points
  632. const relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
  633. // Extract query terms for relevance checking
  634. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  635. // Sort files: highest relevance first, deprioritize low-value files
  636. const sortedFiles = relevantFiles.sort((a, b) => {
  637. const aPath = a[0].toLowerCase();
  638. const bPath = b[0].toLowerCase();
  639. // Check if any node name or file path relates to query terms
  640. const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
  641. const fp = filePath.toLowerCase();
  642. if (queryTerms.some(t => fp.includes(t))) return true;
  643. return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
  644. };
  645. const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
  646. const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
  647. if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
  648. // Deprioritize test files, icon files, and i18n files
  649. const isLowValue = (p: string) =>
  650. /\/(tests?|__tests?__|spec)\//i.test(p) ||
  651. /\bicons?\b/i.test(p) ||
  652. /\bi18n\b/i.test(p);
  653. const aLow = isLowValue(aPath);
  654. const bLow = isLowValue(bPath);
  655. if (aLow !== bLow) return aLow ? 1 : -1;
  656. if (a[1].score !== b[1].score) return b[1].score - a[1].score;
  657. return b[1].nodes.length - a[1].nodes.length;
  658. });
  659. // Step 3: Build relationship map
  660. const lines: string[] = [
  661. `## Exploration: ${query}`,
  662. '',
  663. `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
  664. '',
  665. ];
  666. // Relationship map — show how symbols connect
  667. const significantEdges = subgraph.edges.filter(e =>
  668. e.kind !== 'contains' // skip contains — it's implied by file grouping
  669. );
  670. if (significantEdges.length > 0) {
  671. lines.push('### Relationships');
  672. lines.push('');
  673. // Group edges by kind for readability
  674. const byKind = new Map<string, Array<{ source: string; target: string }>>();
  675. for (const edge of significantEdges) {
  676. const sourceNode = subgraph.nodes.get(edge.source);
  677. const targetNode = subgraph.nodes.get(edge.target);
  678. if (!sourceNode || !targetNode) continue;
  679. const group = byKind.get(edge.kind) || [];
  680. group.push({ source: sourceNode.name, target: targetNode.name });
  681. byKind.set(edge.kind, group);
  682. }
  683. for (const [kind, edges] of byKind) {
  684. // Show up to 15 relationships per kind
  685. const shown = edges.slice(0, 15);
  686. lines.push(`**${kind}:**`);
  687. for (const e of shown) {
  688. lines.push(`- ${e.source} → ${e.target}`);
  689. }
  690. if (edges.length > 15) {
  691. lines.push(`- ... and ${edges.length - 15} more`);
  692. }
  693. lines.push('');
  694. }
  695. }
  696. // Step 4: Read contiguous file sections
  697. lines.push('### Source Code');
  698. lines.push('');
  699. let totalChars = lines.join('\n').length;
  700. let filesIncluded = 0;
  701. for (const [filePath, group] of sortedFiles) {
  702. if (filesIncluded >= maxFiles) break;
  703. if (totalChars > ToolHandler.EXPLORE_MAX_OUTPUT * 0.9) break;
  704. const absPath = validatePathWithinRoot(projectRoot, filePath);
  705. if (!absPath || !existsSync(absPath)) continue;
  706. let fileContent: string;
  707. try {
  708. fileContent = readFileSync(absPath, 'utf-8');
  709. } catch {
  710. continue;
  711. }
  712. const fileLines = fileContent.split('\n');
  713. const lang = group.nodes[0]?.language || '';
  714. // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
  715. // Sort by start line, then merge overlapping/adjacent ranges (within 15 lines).
  716. const ranges = group.nodes
  717. .filter(n => n.startLine > 0 && n.endLine > 0)
  718. .map(n => ({ start: n.startLine, end: n.endLine, name: n.name, kind: n.kind }))
  719. .sort((a, b) => a.start - b.start);
  720. if (ranges.length === 0) continue;
  721. const GAP_THRESHOLD = 15; // merge sections within 15 lines of each other
  722. const clusters: Array<{ start: number; end: number; symbols: string[] }> = [];
  723. let current = { start: ranges[0]!.start, end: ranges[0]!.end, symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`] };
  724. for (let i = 1; i < ranges.length; i++) {
  725. const r = ranges[i]!;
  726. if (r.start <= current.end + GAP_THRESHOLD) {
  727. current.end = Math.max(current.end, r.end);
  728. current.symbols.push(`${r.name}(${r.kind})`);
  729. } else {
  730. clusters.push(current);
  731. current = { start: r.start, end: r.end, symbols: [`${r.name}(${r.kind})`] };
  732. }
  733. }
  734. clusters.push(current);
  735. // Build file section output from clusters
  736. const contextPadding = 3;
  737. let fileSection = '';
  738. const allSymbols: string[] = [];
  739. for (const cluster of clusters) {
  740. const startIdx = Math.max(0, cluster.start - 1 - contextPadding);
  741. const endIdx = Math.min(fileLines.length, cluster.end + contextPadding);
  742. const section = fileLines.slice(startIdx, endIdx).join('\n');
  743. if (fileSection.length > 0) {
  744. fileSection += '\n\n// ... (gap) ...\n\n';
  745. }
  746. fileSection += section;
  747. allSymbols.push(...cluster.symbols);
  748. }
  749. // Skip if this section would blow the output limit
  750. if (totalChars + fileSection.length + 200 > ToolHandler.EXPLORE_MAX_OUTPUT) {
  751. const budget = ToolHandler.EXPLORE_MAX_OUTPUT - totalChars - 200;
  752. if (budget < 500) break;
  753. const trimmed = fileSection.slice(0, budget) + '\n// ... trimmed ...';
  754. lines.push(`#### ${filePath} — ${allSymbols.join(', ')}`);
  755. lines.push('');
  756. lines.push('```' + lang);
  757. lines.push(trimmed);
  758. lines.push('```');
  759. lines.push('');
  760. totalChars += trimmed.length + 200;
  761. filesIncluded++;
  762. break;
  763. }
  764. lines.push(`#### ${filePath} — ${allSymbols.join(', ')}`);
  765. lines.push('');
  766. lines.push('```' + lang);
  767. lines.push(fileSection);
  768. lines.push('```');
  769. lines.push('');
  770. totalChars += fileSection.length + 200;
  771. filesIncluded++;
  772. }
  773. // Add remaining files as references (from both relevant and peripheral files)
  774. const remainingRelevant = sortedFiles.slice(filesIncluded);
  775. const peripheralFiles = [...fileGroups.entries()]
  776. .filter(([, group]) => group.score < 3)
  777. .sort((a, b) => b[1].score - a[1].score);
  778. const remainingFiles = [...remainingRelevant, ...peripheralFiles];
  779. if (remainingFiles.length > 0) {
  780. lines.push('### Additional relevant files (not shown)');
  781. lines.push('');
  782. for (const [filePath, group] of remainingFiles.slice(0, 10)) {
  783. const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  784. lines.push(`- ${filePath}: ${symbols}`);
  785. }
  786. if (remainingFiles.length > 10) {
  787. lines.push(`- ... and ${remainingFiles.length - 10} more files`);
  788. }
  789. }
  790. // Add completeness signal so agents know they don't need to re-read these files
  791. lines.push('');
  792. lines.push('---');
  793. 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.`);
  794. // Add explore budget note based on project size
  795. try {
  796. const stats = cg.getStats();
  797. const budget = getExploreBudget(stats.fileCount);
  798. lines.push('');
  799. lines.push(`> **Explore budget: ${budget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${budget} calls — do NOT make additional explore calls beyond this budget.`);
  800. } catch {
  801. // Stats unavailable — skip budget note
  802. }
  803. return this.textResult(lines.join('\n'));
  804. }
  805. /**
  806. * Handle codegraph_node
  807. */
  808. private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
  809. const symbol = this.validateString(args.symbol, 'symbol');
  810. if (typeof symbol !== 'string') return symbol;
  811. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  812. // Default to false to minimize context usage
  813. const includeCode = args.includeCode === true;
  814. const match = this.findSymbol(cg, symbol);
  815. if (!match) {
  816. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  817. }
  818. let code: string | null = null;
  819. if (includeCode) {
  820. code = await cg.getCode(match.node.id);
  821. }
  822. const formatted = this.formatNodeDetails(match.node, code) + match.note;
  823. return this.textResult(this.truncateOutput(formatted));
  824. }
  825. /**
  826. * Handle codegraph_status
  827. */
  828. private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
  829. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  830. const stats = cg.getStats();
  831. const lines: string[] = [
  832. '## CodeGraph Status',
  833. '',
  834. `**Files indexed:** ${stats.fileCount}`,
  835. `**Total nodes:** ${stats.nodeCount}`,
  836. `**Total edges:** ${stats.edgeCount}`,
  837. `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
  838. '',
  839. '### Nodes by Kind:',
  840. ];
  841. for (const [kind, count] of Object.entries(stats.nodesByKind)) {
  842. if ((count as number) > 0) {
  843. lines.push(`- ${kind}: ${count}`);
  844. }
  845. }
  846. lines.push('', '### Languages:');
  847. for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
  848. if ((count as number) > 0) {
  849. lines.push(`- ${lang}: ${count}`);
  850. }
  851. }
  852. return this.textResult(lines.join('\n'));
  853. }
  854. /**
  855. * Handle codegraph_files - get project file structure from the index
  856. */
  857. private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
  858. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  859. const pathFilter = args.path as string | undefined;
  860. const pattern = args.pattern as string | undefined;
  861. const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
  862. const includeMetadata = args.includeMetadata !== false;
  863. const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
  864. // Get all files from the index
  865. const allFiles = cg.getFiles();
  866. if (allFiles.length === 0) {
  867. return this.textResult('No files indexed. Run `codegraph index` first.');
  868. }
  869. // Filter by path prefix
  870. let files = pathFilter
  871. ? allFiles.filter(f => f.path.startsWith(pathFilter) || f.path.startsWith('./' + pathFilter))
  872. : allFiles;
  873. // Filter by glob pattern
  874. if (pattern) {
  875. const regex = this.globToRegex(pattern);
  876. files = files.filter(f => regex.test(f.path));
  877. }
  878. if (files.length === 0) {
  879. return this.textResult(`No files found matching the criteria.`);
  880. }
  881. // Format output
  882. let output: string;
  883. switch (format) {
  884. case 'flat':
  885. output = this.formatFilesFlat(files, includeMetadata);
  886. break;
  887. case 'grouped':
  888. output = this.formatFilesGrouped(files, includeMetadata);
  889. break;
  890. case 'tree':
  891. default:
  892. output = this.formatFilesTree(files, includeMetadata, maxDepth);
  893. break;
  894. }
  895. return this.textResult(this.truncateOutput(output));
  896. }
  897. /**
  898. * Convert glob pattern to regex
  899. */
  900. private globToRegex(pattern: string): RegExp {
  901. const escaped = pattern
  902. .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
  903. .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
  904. .replace(/\*/g, '[^/]*') // * matches anything except /
  905. .replace(/\?/g, '[^/]') // ? matches single char except /
  906. .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
  907. return new RegExp(escaped);
  908. }
  909. /**
  910. * Format files as a flat list
  911. */
  912. private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  913. const lines: string[] = [`## Files (${files.length})`, ''];
  914. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  915. if (includeMetadata) {
  916. lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
  917. } else {
  918. lines.push(`- ${file.path}`);
  919. }
  920. }
  921. return lines.join('\n');
  922. }
  923. /**
  924. * Format files grouped by language
  925. */
  926. private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  927. const byLang = new Map<string, typeof files>();
  928. for (const file of files) {
  929. const existing = byLang.get(file.language) || [];
  930. existing.push(file);
  931. byLang.set(file.language, existing);
  932. }
  933. const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
  934. // Sort languages by file count (descending)
  935. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  936. for (const [lang, langFiles] of sortedLangs) {
  937. lines.push(`### ${lang} (${langFiles.length})`);
  938. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  939. if (includeMetadata) {
  940. lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
  941. } else {
  942. lines.push(`- ${file.path}`);
  943. }
  944. }
  945. lines.push('');
  946. }
  947. return lines.join('\n');
  948. }
  949. /**
  950. * Format files as a tree structure
  951. */
  952. private formatFilesTree(
  953. files: { path: string; language: string; nodeCount: number }[],
  954. includeMetadata: boolean,
  955. maxDepth?: number
  956. ): string {
  957. // Build tree structure
  958. interface TreeNode {
  959. name: string;
  960. children: Map<string, TreeNode>;
  961. file?: { language: string; nodeCount: number };
  962. }
  963. const root: TreeNode = { name: '', children: new Map() };
  964. for (const file of files) {
  965. const parts = file.path.split('/');
  966. let current = root;
  967. for (let i = 0; i < parts.length; i++) {
  968. const part = parts[i];
  969. if (!part) continue;
  970. if (!current.children.has(part)) {
  971. current.children.set(part, { name: part, children: new Map() });
  972. }
  973. current = current.children.get(part)!;
  974. // If this is the last part, it's a file
  975. if (i === parts.length - 1) {
  976. current.file = { language: file.language, nodeCount: file.nodeCount };
  977. }
  978. }
  979. }
  980. // Render tree
  981. const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
  982. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  983. if (maxDepth !== undefined && depth > maxDepth) return;
  984. const connector = isLast ? '└── ' : '├── ';
  985. const childPrefix = isLast ? ' ' : '│ ';
  986. if (node.name) {
  987. let line = prefix + connector + node.name;
  988. if (node.file && includeMetadata) {
  989. line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
  990. }
  991. lines.push(line);
  992. }
  993. const children = [...node.children.values()];
  994. // Sort: directories first, then files, both alphabetically
  995. children.sort((a, b) => {
  996. const aIsDir = a.children.size > 0 && !a.file;
  997. const bIsDir = b.children.size > 0 && !b.file;
  998. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  999. return a.name.localeCompare(b.name);
  1000. });
  1001. for (let i = 0; i < children.length; i++) {
  1002. const child = children[i]!;
  1003. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  1004. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  1005. }
  1006. };
  1007. renderNode(root, '', true, 0);
  1008. return lines.join('\n');
  1009. }
  1010. // =========================================================================
  1011. // Symbol resolution helpers
  1012. // =========================================================================
  1013. /**
  1014. * Find a symbol by name, handling disambiguation when multiple matches exist.
  1015. * Returns the best match and a note about alternatives if any.
  1016. */
  1017. /**
  1018. * Check if a node matches a symbol query, supporting both simple names and
  1019. * qualified "Parent.child" notation (e.g., "Session.request" matches a method
  1020. * named "request" inside a class named "Session").
  1021. */
  1022. private matchesSymbol(node: Node, symbol: string): boolean {
  1023. // Simple name match
  1024. if (node.name === symbol) return true;
  1025. // File basename match (e.g., "product-card" matches "product-card.liquid")
  1026. if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
  1027. // Qualified name match: "Parent.child" → look for "::Parent::child" in qualified_name
  1028. if (symbol.includes('.')) {
  1029. const parts = symbol.split('.');
  1030. const qualifiedSuffix = parts.join('::');
  1031. if (node.qualifiedName.includes(qualifiedSuffix)) return true;
  1032. }
  1033. return false;
  1034. }
  1035. private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
  1036. // Use higher limit for qualified lookups (e.g., "Session.request") since the
  1037. // target may rank lower in FTS when there are many partial matches
  1038. const limit = symbol.includes('.') ? 50 : 10;
  1039. const results = cg.searchNodes(symbol, { limit });
  1040. if (results.length === 0 || !results[0]) {
  1041. return null;
  1042. }
  1043. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  1044. if (exactMatches.length === 1) {
  1045. return { node: exactMatches[0]!.node, note: '' };
  1046. }
  1047. if (exactMatches.length > 1) {
  1048. // Multiple exact matches - pick first, note the others
  1049. const picked = exactMatches[0]!.node;
  1050. const others = exactMatches.slice(1).map(r =>
  1051. `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
  1052. );
  1053. const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
  1054. return { node: picked, note };
  1055. }
  1056. // No exact match, use best fuzzy match
  1057. return { node: results[0]!.node, note: '' };
  1058. }
  1059. /**
  1060. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  1061. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  1062. */
  1063. private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  1064. const results = cg.searchNodes(symbol, { limit: 50 });
  1065. if (results.length === 0) {
  1066. return { nodes: [], note: '' };
  1067. }
  1068. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  1069. if (exactMatches.length <= 1) {
  1070. const node = exactMatches[0]?.node ?? results[0]!.node;
  1071. return { nodes: [node], note: '' };
  1072. }
  1073. const locations = exactMatches.map(r =>
  1074. `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
  1075. );
  1076. const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
  1077. return { nodes: exactMatches.map(r => r.node), note };
  1078. }
  1079. /**
  1080. * Truncate output if it exceeds the maximum length
  1081. */
  1082. private truncateOutput(text: string): string {
  1083. if (text.length <= MAX_OUTPUT_LENGTH) return text;
  1084. const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
  1085. const lastNewline = truncated.lastIndexOf('\n');
  1086. const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
  1087. return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
  1088. }
  1089. // =========================================================================
  1090. // Formatting helpers (compact by default to reduce context usage)
  1091. // =========================================================================
  1092. private formatSearchResults(results: SearchResult[]): string {
  1093. const lines: string[] = [`## Search Results (${results.length} found)`, ''];
  1094. for (const result of results) {
  1095. const { node } = result;
  1096. const location = node.startLine ? `:${node.startLine}` : '';
  1097. // Compact format: one line per result with key info
  1098. lines.push(`### ${node.name} (${node.kind})`);
  1099. lines.push(`${node.filePath}${location}`);
  1100. if (node.signature) lines.push(`\`${node.signature}\``);
  1101. lines.push('');
  1102. }
  1103. return lines.join('\n');
  1104. }
  1105. private formatNodeList(nodes: Node[], title: string): string {
  1106. const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
  1107. for (const node of nodes) {
  1108. const location = node.startLine ? `:${node.startLine}` : '';
  1109. // Compact: just name, kind, location
  1110. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
  1111. }
  1112. return lines.join('\n');
  1113. }
  1114. private formatImpact(symbol: string, impact: Subgraph): string {
  1115. const nodeCount = impact.nodes.size;
  1116. // Compact format: just list affected symbols grouped by file
  1117. const lines: string[] = [
  1118. `## Impact: "${symbol}" affects ${nodeCount} symbols`,
  1119. '',
  1120. ];
  1121. // Group by file
  1122. const byFile = new Map<string, Node[]>();
  1123. for (const node of impact.nodes.values()) {
  1124. const existing = byFile.get(node.filePath) || [];
  1125. existing.push(node);
  1126. byFile.set(node.filePath, existing);
  1127. }
  1128. for (const [file, nodes] of byFile) {
  1129. lines.push(`**${file}:**`);
  1130. // Compact: inline list
  1131. const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  1132. lines.push(nodeList);
  1133. lines.push('');
  1134. }
  1135. return lines.join('\n');
  1136. }
  1137. private formatNodeDetails(node: Node, code: string | null): string {
  1138. const location = node.startLine ? `:${node.startLine}` : '';
  1139. const lines: string[] = [
  1140. `## ${node.name} (${node.kind})`,
  1141. '',
  1142. `**Location:** ${node.filePath}${location}`,
  1143. ];
  1144. if (node.signature) {
  1145. lines.push(`**Signature:** \`${node.signature}\``);
  1146. }
  1147. // Only include docstring if it's short and useful
  1148. if (node.docstring && node.docstring.length < 200) {
  1149. lines.push('', node.docstring);
  1150. }
  1151. if (code) {
  1152. lines.push('', '```' + node.language, code, '```');
  1153. }
  1154. return lines.join('\n');
  1155. }
  1156. private formatTaskContext(context: TaskContext): string {
  1157. return context.summary || 'No context found';
  1158. }
  1159. private textResult(text: string): ToolResult {
  1160. return {
  1161. content: [{ type: 'text', text }],
  1162. };
  1163. }
  1164. private errorResult(message: string): ToolResult {
  1165. return {
  1166. content: [{ type: 'text', text: `Error: ${message}` }],
  1167. isError: true,
  1168. };
  1169. }
  1170. }