tools.ts 90 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260
  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 {
  10. constants as fsConstants,
  11. closeSync,
  12. existsSync,
  13. lstatSync,
  14. openSync,
  15. readFileSync,
  16. writeSync,
  17. } from 'fs';
  18. import { clamp, validatePathWithinRoot, validateProjectPath } from '../utils';
  19. import { tmpdir } from 'os';
  20. import { join } from 'path';
  21. /** Maximum output length to prevent context bloat (characters) */
  22. const MAX_OUTPUT_LENGTH = 15000;
  23. /**
  24. * Maximum length for free-form string inputs (query, task, symbol).
  25. * Bounds memory and CPU when a buggy or hostile MCP client sends a
  26. * huge payload — without this an attacker could ship a 100MB string
  27. * and force a full FTS5 scan / OOM the server. 10 000 characters is
  28. * far beyond any realistic legitimate query.
  29. */
  30. const MAX_INPUT_LENGTH = 10_000;
  31. /**
  32. * Maximum length for path-like string inputs (projectPath, path
  33. * filter, glob pattern). Paths beyond a few thousand chars are
  34. * never legitimate and signal abuse or a bug upstream.
  35. */
  36. const MAX_PATH_LENGTH = 4_096;
  37. /**
  38. * Rust path roots that have no file-system equivalent — `crate` is the
  39. * current crate, `super` is the parent module, `self` is the current
  40. * module. Used by `matchesSymbol` to strip these before file-path
  41. * matching so `crate::configurator::stage_apply::run` resolves the
  42. * same as `configurator::stage_apply::run`.
  43. */
  44. const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
  45. /**
  46. * Node kinds that contain other symbols. For these, `codegraph_node` with
  47. * `includeCode=true` returns a structural outline (member names + signatures
  48. * + line numbers) instead of the full body, which for a large class is a
  49. * multi-thousand-character wall of source that bloats the agent's context.
  50. */
  51. const CONTAINER_NODE_KINDS = new Set<NodeKind>([
  52. 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
  53. ]);
  54. /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
  55. function lastQualifierPart(symbol: string): string {
  56. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  57. return parts[parts.length - 1] ?? symbol;
  58. }
  59. /**
  60. * Calculate the recommended number of codegraph_explore calls based on project size.
  61. * Larger codebases need more exploration calls to cover their surface area,
  62. * but smaller ones should use fewer to avoid unnecessary overhead.
  63. */
  64. export function getExploreBudget(fileCount: number): number {
  65. if (fileCount < 500) return 1;
  66. if (fileCount < 5000) return 2;
  67. if (fileCount < 15000) return 3;
  68. if (fileCount < 25000) return 4;
  69. return 5;
  70. }
  71. /**
  72. * Adaptive output budget for `codegraph_explore`, scaled to project size.
  73. *
  74. * Smaller codebases get a tighter total cap, fewer default files, smaller
  75. * per-file cap, and tighter clustering — so a focused query on a 100-file
  76. * project doesn't dump a whole file's worth of source into the agent's
  77. * context. Larger codebases keep the generous defaults because the
  78. * agent's native discovery cost (grep + find + many Reads) genuinely
  79. * dwarfs a fat explore call at that scale.
  80. *
  81. * Meta-text (relationships map, "additional relevant files" list,
  82. * completeness signal, budget note) is gated off for tiny projects
  83. * where one rich call is the whole story and the extra prose is just
  84. * overhead.
  85. *
  86. * Tier breakpoints mirror `getExploreBudget` so a project sits in the
  87. * same tier across both knobs.
  88. */
  89. export interface ExploreOutputBudget {
  90. /** Hard cap on total output characters. */
  91. maxOutputChars: number;
  92. /** Default `maxFiles` when the caller didn't specify one. */
  93. defaultMaxFiles: number;
  94. /** Cap on contiguous source returned per file (across all its clusters). */
  95. maxCharsPerFile: number;
  96. /** Cluster gap threshold in lines — tighter clustering on small projects. */
  97. gapThreshold: number;
  98. /** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */
  99. maxSymbolsInFileHeader: number;
  100. /** Max edges shown per relationship kind in the Relationships section. */
  101. maxEdgesPerRelationshipKind: number;
  102. /** Include the "Relationships" section. */
  103. includeRelationships: boolean;
  104. /** Include the "Additional relevant files (not shown)" trailing list. */
  105. includeAdditionalFiles: boolean;
  106. /** Include the "Complete source code is included above…" reminder. */
  107. includeCompletenessSignal: boolean;
  108. /** Include the explore-budget reminder at the end. */
  109. includeBudgetNote: boolean;
  110. }
  111. export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
  112. if (fileCount < 500) {
  113. return {
  114. maxOutputChars: 18000,
  115. defaultMaxFiles: 5,
  116. maxCharsPerFile: 3800,
  117. gapThreshold: 8,
  118. maxSymbolsInFileHeader: 6,
  119. maxEdgesPerRelationshipKind: 6,
  120. includeRelationships: true,
  121. includeAdditionalFiles: false,
  122. includeCompletenessSignal: false,
  123. includeBudgetNote: false,
  124. };
  125. }
  126. if (fileCount < 5000) {
  127. return {
  128. // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
  129. // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
  130. // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
  131. // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
  132. // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
  133. maxOutputChars: 28000,
  134. defaultMaxFiles: 10,
  135. maxCharsPerFile: 6500,
  136. gapThreshold: 12,
  137. maxSymbolsInFileHeader: 10,
  138. maxEdgesPerRelationshipKind: 10,
  139. includeRelationships: true,
  140. includeAdditionalFiles: true,
  141. includeCompletenessSignal: true,
  142. includeBudgetNote: true,
  143. };
  144. }
  145. if (fileCount < 15000) {
  146. return {
  147. maxOutputChars: 35000,
  148. defaultMaxFiles: 12,
  149. maxCharsPerFile: 7000,
  150. gapThreshold: 15,
  151. maxSymbolsInFileHeader: 15,
  152. maxEdgesPerRelationshipKind: 15,
  153. includeRelationships: true,
  154. includeAdditionalFiles: true,
  155. includeCompletenessSignal: true,
  156. includeBudgetNote: true,
  157. };
  158. }
  159. return {
  160. maxOutputChars: 38000,
  161. defaultMaxFiles: 14,
  162. maxCharsPerFile: 7000,
  163. gapThreshold: 15,
  164. maxSymbolsInFileHeader: 15,
  165. maxEdgesPerRelationshipKind: 15,
  166. includeRelationships: true,
  167. includeAdditionalFiles: true,
  168. includeCompletenessSignal: true,
  169. includeBudgetNote: true,
  170. };
  171. }
  172. /**
  173. * Whether `codegraph_explore` should prefix source lines with their line
  174. * numbers (cat -n style: `<num>\t<code>`).
  175. *
  176. * Line numbers let the agent cite `file:line` straight from the explore
  177. * payload instead of re-Reading the file just to find a line number — the
  178. * dominant residual cost on precise-tracing questions (#185 follow-up).
  179. *
  180. * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
  181. * A/B harness to measure the payload-cost vs. read-savings tradeoff).
  182. */
  183. function exploreLineNumbersEnabled(): boolean {
  184. return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
  185. }
  186. /**
  187. * Prefix each line of a source slice with its 1-based line number, matching
  188. * the Read tool's `cat -n` convention (number + tab) so the agent treats it
  189. * the same way it treats Read output.
  190. *
  191. * @param slice contiguous source text (already extracted from the file)
  192. * @param firstLineNumber the 1-based line number of the slice's first line
  193. */
  194. function numberSourceLines(slice: string, firstLineNumber: number): string {
  195. const out: string[] = [];
  196. const split = slice.split('\n');
  197. for (let i = 0; i < split.length; i++) {
  198. out.push(`${firstLineNumber + i}\t${split[i]}`);
  199. }
  200. return out.join('\n');
  201. }
  202. /**
  203. * Mark a Claude session as having consulted MCP tools.
  204. * This enables Grep/Glob/Bash commands that would otherwise be blocked.
  205. *
  206. * Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
  207. * tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
  208. * machine any other local user can pre-create `codegraph-consulted-<hash>` as
  209. * a symlink pointing at a file the victim owns. The old `writeFileSync` would
  210. * happily follow that link and overwrite the target's contents with the ISO
  211. * timestamp string (CWE-59). The session-id hash provides the predictability
  212. * gate, but it's defense-in-depth: if a session id ever surfaces in logs,
  213. * argv, or telemetry the attack becomes trivial, and the right fix is to not
  214. * follow links from /tmp paths in the first place.
  215. */
  216. function markSessionConsulted(sessionId: string): void {
  217. try {
  218. const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
  219. const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
  220. // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
  221. // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
  222. // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
  223. // drops it and openSync would follow the link. This lstat check closes that
  224. // gap cross-platform; ENOENT (path is free) falls through to create it.
  225. try {
  226. if (lstatSync(markerPath).isSymbolicLink()) return;
  227. } catch {
  228. // No existing entry (or stat failed) — nothing to refuse; proceed.
  229. }
  230. // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
  231. // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
  232. // mode 0o600 prevents readback by other local users (the marker payload is
  233. // benign, but narrowing the exposure costs nothing).
  234. const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
  235. const fd = openSync(markerPath, flags, 0o600);
  236. try {
  237. writeSync(fd, new Date().toISOString());
  238. } finally {
  239. closeSync(fd);
  240. }
  241. } catch {
  242. // Silently fail - don't break MCP on marker write failure. ELOOP from a
  243. // planted symlink lands here too, which is the intended behavior: refuse
  244. // to write rather than overwrite an attacker-chosen target.
  245. }
  246. }
  247. /**
  248. * MCP Tool definition
  249. */
  250. export interface ToolDefinition {
  251. name: string;
  252. description: string;
  253. inputSchema: {
  254. type: 'object';
  255. properties: Record<string, PropertySchema>;
  256. required?: string[];
  257. };
  258. }
  259. interface PropertySchema {
  260. type: string;
  261. description: string;
  262. enum?: string[];
  263. default?: unknown;
  264. }
  265. /**
  266. * Tool execution result
  267. */
  268. export interface ToolResult {
  269. content: Array<{
  270. type: 'text';
  271. text: string;
  272. }>;
  273. isError?: boolean;
  274. }
  275. /**
  276. * Common projectPath property for cross-project queries
  277. */
  278. const projectPathProperty: PropertySchema = {
  279. type: 'string',
  280. description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
  281. };
  282. /**
  283. * All CodeGraph MCP tools
  284. *
  285. * Designed for minimal context usage - use codegraph_context as the primary tool,
  286. * and only use other tools for targeted follow-up queries.
  287. *
  288. * All tools support cross-project queries via the optional `projectPath` parameter.
  289. */
  290. export const tools: ToolDefinition[] = [
  291. {
  292. name: 'codegraph_search',
  293. description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
  294. inputSchema: {
  295. type: 'object',
  296. properties: {
  297. query: {
  298. type: 'string',
  299. description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
  300. },
  301. kind: {
  302. type: 'string',
  303. description: 'Filter by node kind',
  304. enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
  305. },
  306. limit: {
  307. type: 'number',
  308. description: 'Maximum results (default: 10)',
  309. default: 10,
  310. },
  311. projectPath: projectPathProperty,
  312. },
  313. required: ['query'],
  314. },
  315. },
  316. {
  317. name: 'codegraph_context',
  318. description: 'PRIMARY TOOL — call this FIRST for any "how does X work", architecture, feature, or bug-context question. Composes search + node + callers + callees and returns entry points, related symbols, and key code in ONE call — usually enough to answer with no further search/Read/Grep. Prefer this over chaining codegraph_search + codegraph_node, and over codegraph_explore. NOTE: provides CODE context, not product requirements; for new features still clarify UX/edge cases with the user.',
  319. inputSchema: {
  320. type: 'object',
  321. properties: {
  322. task: {
  323. type: 'string',
  324. description: 'Description of the task, bug, or feature to build context for',
  325. },
  326. maxNodes: {
  327. type: 'number',
  328. description: 'Maximum symbols to include (default: 20)',
  329. default: 20,
  330. },
  331. includeCode: {
  332. type: 'boolean',
  333. description: 'Include code snippets for key symbols (default: true)',
  334. default: true,
  335. },
  336. projectPath: projectPathProperty,
  337. },
  338. required: ['task'],
  339. },
  340. },
  341. {
  342. name: 'codegraph_callers',
  343. description: 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.',
  344. inputSchema: {
  345. type: 'object',
  346. properties: {
  347. symbol: {
  348. type: 'string',
  349. description: 'Name of the function, method, or class to find callers for',
  350. },
  351. limit: {
  352. type: 'number',
  353. description: 'Maximum number of callers to return (default: 20)',
  354. default: 20,
  355. },
  356. projectPath: projectPathProperty,
  357. },
  358. required: ['symbol'],
  359. },
  360. },
  361. {
  362. name: 'codegraph_callees',
  363. description: 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.',
  364. inputSchema: {
  365. type: 'object',
  366. properties: {
  367. symbol: {
  368. type: 'string',
  369. description: 'Name of the function, method, or class to find callees for',
  370. },
  371. limit: {
  372. type: 'number',
  373. description: 'Maximum number of callees to return (default: 20)',
  374. default: 20,
  375. },
  376. projectPath: projectPathProperty,
  377. },
  378. required: ['symbol'],
  379. },
  380. },
  381. {
  382. name: 'codegraph_impact',
  383. description: 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.',
  384. inputSchema: {
  385. type: 'object',
  386. properties: {
  387. symbol: {
  388. type: 'string',
  389. description: 'Name of the symbol to analyze impact for',
  390. },
  391. depth: {
  392. type: 'number',
  393. description: 'How many levels of dependencies to traverse (default: 2)',
  394. default: 2,
  395. },
  396. projectPath: projectPathProperty,
  397. },
  398. required: ['symbol'],
  399. },
  400. },
  401. {
  402. name: 'codegraph_node',
  403. description: 'Get ONE symbol\'s details (location, signature, docstring) PLUS its TRAIL — what it calls and what calls it, each with file:line. Pass includeCode=true for source (functions return their body; containers return a member outline). Use this to WALK the call graph hop-by-hop — node a symbol, then node one of its trail entries — the structural, no-Read way to follow "what calls/triggers/handles X" across files. For a broad first overview of many symbols at once use codegraph_explore; use node to drill along a specific path from there. (If a trail is empty on a non-leaf, that hop is likely dynamic dispatch — read just that line.) Source returned with includeCode is the verbatim live file content — identical to Read.',
  404. inputSchema: {
  405. type: 'object',
  406. properties: {
  407. symbol: {
  408. type: 'string',
  409. description: 'Name of the symbol to get details for',
  410. },
  411. includeCode: {
  412. type: 'boolean',
  413. description: 'Include full source code (default: false to minimize context)',
  414. default: false,
  415. },
  416. projectPath: projectPathProperty,
  417. },
  418. required: ['symbol'],
  419. },
  420. },
  421. {
  422. name: 'codegraph_explore',
  423. description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts". The code it returns is the VERBATIM live file source (byte-for-byte identical to Read), line-numbered — not a summary; treat files it shows as already Read, no need to re-open them.',
  424. inputSchema: {
  425. type: 'object',
  426. properties: {
  427. query: {
  428. type: 'string',
  429. description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.',
  430. },
  431. maxFiles: {
  432. type: 'number',
  433. description: 'Maximum number of files to include source code from (default: 12)',
  434. default: 12,
  435. },
  436. projectPath: projectPathProperty,
  437. },
  438. required: ['query'],
  439. },
  440. },
  441. {
  442. name: 'codegraph_status',
  443. description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.',
  444. inputSchema: {
  445. type: 'object',
  446. properties: {
  447. projectPath: projectPathProperty,
  448. },
  449. },
  450. },
  451. {
  452. name: 'codegraph_files',
  453. 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.',
  454. inputSchema: {
  455. type: 'object',
  456. properties: {
  457. path: {
  458. type: 'string',
  459. description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
  460. },
  461. pattern: {
  462. type: 'string',
  463. description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
  464. },
  465. format: {
  466. type: 'string',
  467. description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
  468. enum: ['tree', 'flat', 'grouped'],
  469. default: 'tree',
  470. },
  471. includeMetadata: {
  472. type: 'boolean',
  473. description: 'Include file metadata like language and symbol count (default: true)',
  474. default: true,
  475. },
  476. maxDepth: {
  477. type: 'number',
  478. description: 'Maximum directory depth to show (default: unlimited)',
  479. },
  480. projectPath: projectPathProperty,
  481. },
  482. },
  483. },
  484. {
  485. name: 'codegraph_trace',
  486. description: 'Trace the CALL PATH between two symbols — "how does <from> reach/become <to>?" Returns the chain of functions from one to the other (each hop with file:line + the call-site line) in ONE call. This is something grep/Read structurally cannot do: there is no text pattern for "the path from A to B". Ideal for flow questions — how an update triggers a render, how a request reaches a handler, how a QuerySet becomes SQL. If no static path exists the chain likely breaks at dynamic dispatch (callbacks/descriptors/metaclasses); the tool says where and points you to codegraph_node to bridge it.',
  487. inputSchema: {
  488. type: 'object',
  489. properties: {
  490. from: {
  491. type: 'string',
  492. description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
  493. },
  494. to: {
  495. type: 'string',
  496. description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
  497. },
  498. projectPath: projectPathProperty,
  499. },
  500. required: ['from', 'to'],
  501. },
  502. },
  503. ];
  504. /**
  505. * Tool handler that executes tools against a CodeGraph instance
  506. *
  507. * Supports cross-project queries via the projectPath parameter.
  508. * Other projects are opened on-demand and cached for performance.
  509. */
  510. export class ToolHandler {
  511. // Cache of opened CodeGraph instances for cross-project queries
  512. private projectCache: Map<string, CodeGraph> = new Map();
  513. // The directory the server last searched for a default project. Surfaced in
  514. // the "not initialized" error so users can see why detection missed.
  515. private defaultProjectHint: string | null = null;
  516. constructor(private cg: CodeGraph | null) {}
  517. /**
  518. * Update the default CodeGraph instance (e.g. after lazy initialization)
  519. */
  520. setDefaultCodeGraph(cg: CodeGraph): void {
  521. this.cg = cg;
  522. }
  523. /**
  524. * Record the directory the server tried to resolve the default project from.
  525. * Used only to make the "no default project" error actionable.
  526. */
  527. setDefaultProjectHint(searchedPath: string): void {
  528. this.defaultProjectHint = searchedPath;
  529. }
  530. /**
  531. * Whether a default CodeGraph instance is available
  532. */
  533. hasDefaultCodeGraph(): boolean {
  534. return this.cg !== null;
  535. }
  536. /**
  537. * Get tool definitions with dynamic descriptions based on project size.
  538. * The codegraph_explore tool description includes a budget recommendation
  539. * scaled to the number of indexed files.
  540. */
  541. getTools(): ToolDefinition[] {
  542. if (!this.cg) return tools;
  543. try {
  544. const stats = this.cg.getStats();
  545. const budget = getExploreBudget(stats.fileCount);
  546. return tools.map(tool => {
  547. if (tool.name === 'codegraph_explore') {
  548. return {
  549. ...tool,
  550. description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
  551. };
  552. }
  553. return tool;
  554. });
  555. } catch {
  556. return tools;
  557. }
  558. }
  559. /**
  560. * Get CodeGraph instance for a project
  561. *
  562. * If projectPath is provided, opens that project's CodeGraph (cached).
  563. * Otherwise returns the default CodeGraph instance.
  564. *
  565. * Walks up parent directories to find the nearest .codegraph/ folder,
  566. * similar to how git finds .git/ directories.
  567. */
  568. private getCodeGraph(projectPath?: string): CodeGraph {
  569. if (!projectPath) {
  570. if (!this.cg) {
  571. const searched = this.defaultProjectHint ?? process.cwd();
  572. throw new Error(
  573. 'No CodeGraph project is loaded for this session.\n' +
  574. `Searched for a .codegraph/ directory starting from: ${searched}\n` +
  575. 'The index is likely fine — this is a working-directory detection issue: ' +
  576. "the MCP client launched the server outside your project and didn't report the " +
  577. 'workspace root. Fix it either way:\n' +
  578. ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
  579. ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
  580. );
  581. }
  582. return this.cg;
  583. }
  584. // Check cache first (using original path as key)
  585. if (this.projectCache.has(projectPath)) {
  586. return this.projectCache.get(projectPath)!;
  587. }
  588. // Reject sensitive system directories before opening. Only validate a
  589. // path that actually exists — a nested or not-yet-created sub-path of a
  590. // real project must still be allowed to resolve UP to its .codegraph/
  591. // root below (issue #238), so we don't run the existence-checking
  592. // validator on paths that are meant to walk up.
  593. if (existsSync(projectPath)) {
  594. const pathError = validateProjectPath(projectPath);
  595. if (pathError) {
  596. throw new Error(pathError);
  597. }
  598. }
  599. // Walk up parent directories to find nearest .codegraph/
  600. const resolvedRoot = findNearestCodeGraphRoot(projectPath);
  601. if (!resolvedRoot) {
  602. throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
  603. }
  604. // If the path resolves to the default project, reuse the already-open
  605. // default instance rather than opening a SECOND connection to the same DB.
  606. // A duplicate connection serializes reads against the watcher's auto-sync
  607. // writes; on the wasm backend (no WAL) that surfaces as intermittent
  608. // "database is locked" on concurrent tool calls. See issue #238. Deliberately
  609. // not cached under projectPath — the server owns and closes the default
  610. // instance, so routing it through projectCache.closeAll() would double-close it.
  611. if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
  612. return this.cg;
  613. }
  614. // Check if we already have this resolved root cached (different path, same project)
  615. if (this.projectCache.has(resolvedRoot)) {
  616. const cg = this.projectCache.get(resolvedRoot)!;
  617. // Cache under original path too for faster future lookups
  618. this.projectCache.set(projectPath, cg);
  619. return cg;
  620. }
  621. // Open and cache under both paths
  622. const cg = CodeGraph.openSync(resolvedRoot);
  623. this.projectCache.set(resolvedRoot, cg);
  624. if (projectPath !== resolvedRoot) {
  625. this.projectCache.set(projectPath, cg);
  626. }
  627. return cg;
  628. }
  629. /**
  630. * Close all cached project connections
  631. */
  632. closeAll(): void {
  633. for (const cg of this.projectCache.values()) {
  634. cg.close();
  635. }
  636. this.projectCache.clear();
  637. }
  638. /**
  639. * Validate that a value is a non-empty string within length bounds.
  640. *
  641. * The `maxLength` cap protects against MCP clients that ship huge
  642. * payloads (10MB+ query strings either by accident or maliciously).
  643. * Without this, a single oversized input can pin the FTS5 index or
  644. * exhaust memory before any real work runs.
  645. */
  646. private validateString(
  647. value: unknown,
  648. name: string,
  649. maxLength: number = MAX_INPUT_LENGTH
  650. ): string | ToolResult {
  651. if (typeof value !== 'string' || value.length === 0) {
  652. return this.errorResult(`${name} must be a non-empty string`);
  653. }
  654. if (value.length > maxLength) {
  655. return this.errorResult(
  656. `${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`
  657. );
  658. }
  659. return value;
  660. }
  661. /**
  662. * Validate an optional path-like string input. Returns the value if
  663. * valid (or undefined), or a ToolResult with the error.
  664. */
  665. private validateOptionalPath(
  666. value: unknown,
  667. name: string
  668. ): string | undefined | ToolResult {
  669. if (value === undefined || value === null) return undefined;
  670. if (typeof value !== 'string') {
  671. return this.errorResult(`${name} must be a string`);
  672. }
  673. if (value.length > MAX_PATH_LENGTH) {
  674. return this.errorResult(
  675. `${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`
  676. );
  677. }
  678. return value;
  679. }
  680. /**
  681. * Execute a tool by name
  682. */
  683. async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  684. try {
  685. // Cross-cutting input validation. All tools accept an optional
  686. // `projectPath` and most accept either `query`, `task`, or
  687. // `symbol` — bound their lengths centrally so individual handlers
  688. // can stay focused on tool-specific logic.
  689. const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
  690. if (typeof pathCheck === 'object' && pathCheck !== undefined) {
  691. return pathCheck;
  692. }
  693. // The `path` and `pattern` properties used by codegraph_files are
  694. // also path-shaped — apply the same cap.
  695. if (args.path !== undefined) {
  696. const check = this.validateOptionalPath(args.path, 'path');
  697. if (typeof check === 'object' && check !== undefined) return check;
  698. }
  699. if (args.pattern !== undefined) {
  700. const check = this.validateOptionalPath(args.pattern, 'pattern');
  701. if (typeof check === 'object' && check !== undefined) return check;
  702. }
  703. switch (toolName) {
  704. case 'codegraph_search':
  705. return await this.handleSearch(args);
  706. case 'codegraph_context':
  707. return await this.handleContext(args);
  708. case 'codegraph_callers':
  709. return await this.handleCallers(args);
  710. case 'codegraph_callees':
  711. return await this.handleCallees(args);
  712. case 'codegraph_impact':
  713. return await this.handleImpact(args);
  714. case 'codegraph_explore':
  715. return await this.handleExplore(args);
  716. case 'codegraph_node':
  717. return await this.handleNode(args);
  718. case 'codegraph_status':
  719. return await this.handleStatus(args);
  720. case 'codegraph_files':
  721. return await this.handleFiles(args);
  722. case 'codegraph_trace':
  723. return await this.handleTrace(args);
  724. default:
  725. return this.errorResult(`Unknown tool: ${toolName}`);
  726. }
  727. } catch (err) {
  728. return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
  729. }
  730. }
  731. /**
  732. * Handle codegraph_search
  733. */
  734. private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
  735. const query = this.validateString(args.query, 'query');
  736. if (typeof query !== 'string') return query;
  737. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  738. const kind = args.kind as string | undefined;
  739. const rawLimit = Number(args.limit) || 10;
  740. const limit = clamp(rawLimit, 1, 100);
  741. const results = cg.searchNodes(query, {
  742. limit,
  743. kinds: kind ? [kind as NodeKind] : undefined,
  744. });
  745. if (results.length === 0) {
  746. return this.textResult(`No results found for "${query}"`);
  747. }
  748. const formatted = this.formatSearchResults(results);
  749. return this.textResult(this.truncateOutput(formatted));
  750. }
  751. /**
  752. * Handle codegraph_context
  753. */
  754. private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
  755. const task = this.validateString(args.task, 'task');
  756. if (typeof task !== 'string') return task;
  757. // Mark session as consulted (enables Grep/Glob/Bash)
  758. const sessionId = process.env.CLAUDE_SESSION_ID;
  759. if (sessionId) {
  760. markSessionConsulted(sessionId);
  761. }
  762. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  763. const maxNodes = (args.maxNodes as number) || 20;
  764. const includeCode = args.includeCode !== false;
  765. const context = await cg.buildContext(task, {
  766. maxNodes,
  767. includeCode,
  768. format: 'markdown',
  769. });
  770. // Detect if this looks like a feature request (vs bug fix or exploration)
  771. const isFeatureQuery = this.looksLikeFeatureRequest(task);
  772. const reminder = isFeatureQuery
  773. ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
  774. : '';
  775. // buildContext returns string when format is 'markdown'
  776. if (typeof context === 'string') {
  777. return this.textResult(this.truncateOutput(context + reminder));
  778. }
  779. // If it returns TaskContext, format it
  780. return this.textResult(this.truncateOutput(this.formatTaskContext(context) + reminder));
  781. }
  782. /**
  783. * Heuristic to detect if a query looks like a feature request
  784. */
  785. private looksLikeFeatureRequest(task: string): boolean {
  786. const featureKeywords = [
  787. 'add', 'create', 'implement', 'build', 'enable', 'allow',
  788. 'new feature', 'support for', 'ability to', 'want to',
  789. 'should be able', 'need to add', 'swap', 'edit', 'modify'
  790. ];
  791. const bugKeywords = [
  792. 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
  793. 'not working', 'fails', 'undefined', 'null'
  794. ];
  795. const explorationKeywords = [
  796. 'how does', 'where is', 'what is', 'find', 'show me',
  797. 'explain', 'understand', 'explore'
  798. ];
  799. const lowerTask = task.toLowerCase();
  800. // If it's clearly a bug or exploration, not a feature
  801. if (bugKeywords.some(k => lowerTask.includes(k))) return false;
  802. if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
  803. // If it matches feature keywords, it's likely a feature request
  804. return featureKeywords.some(k => lowerTask.includes(k));
  805. }
  806. /**
  807. * Handle codegraph_callers
  808. */
  809. private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
  810. const symbol = this.validateString(args.symbol, 'symbol');
  811. if (typeof symbol !== 'string') return symbol;
  812. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  813. const limit = clamp((args.limit as number) || 20, 1, 100);
  814. const allMatches = this.findAllSymbols(cg, symbol);
  815. if (allMatches.nodes.length === 0) {
  816. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  817. }
  818. // Aggregate callers across all matching symbols
  819. const seen = new Set<string>();
  820. const allCallers: Node[] = [];
  821. for (const node of allMatches.nodes) {
  822. for (const c of cg.getCallers(node.id)) {
  823. if (!seen.has(c.node.id)) {
  824. seen.add(c.node.id);
  825. allCallers.push(c.node);
  826. }
  827. }
  828. }
  829. if (allCallers.length === 0) {
  830. return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
  831. }
  832. const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
  833. return this.textResult(this.truncateOutput(formatted));
  834. }
  835. /**
  836. * Handle codegraph_callees
  837. */
  838. private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
  839. const symbol = this.validateString(args.symbol, 'symbol');
  840. if (typeof symbol !== 'string') return symbol;
  841. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  842. const limit = clamp((args.limit as number) || 20, 1, 100);
  843. const allMatches = this.findAllSymbols(cg, symbol);
  844. if (allMatches.nodes.length === 0) {
  845. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  846. }
  847. // Aggregate callees across all matching symbols
  848. const seen = new Set<string>();
  849. const allCallees: Node[] = [];
  850. for (const node of allMatches.nodes) {
  851. for (const c of cg.getCallees(node.id)) {
  852. if (!seen.has(c.node.id)) {
  853. seen.add(c.node.id);
  854. allCallees.push(c.node);
  855. }
  856. }
  857. }
  858. if (allCallees.length === 0) {
  859. return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
  860. }
  861. const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
  862. return this.textResult(this.truncateOutput(formatted));
  863. }
  864. /**
  865. * Handle codegraph_impact
  866. */
  867. private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
  868. const symbol = this.validateString(args.symbol, 'symbol');
  869. if (typeof symbol !== 'string') return symbol;
  870. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  871. const depth = clamp((args.depth as number) || 2, 1, 10);
  872. const allMatches = this.findAllSymbols(cg, symbol);
  873. if (allMatches.nodes.length === 0) {
  874. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  875. }
  876. // Aggregate impact across all matching symbols
  877. const mergedNodes = new Map<string, Node>();
  878. const mergedEdges: Edge[] = [];
  879. const seenEdges = new Set<string>();
  880. for (const node of allMatches.nodes) {
  881. const impact = cg.getImpactRadius(node.id, depth);
  882. for (const [id, n] of impact.nodes) {
  883. mergedNodes.set(id, n);
  884. }
  885. for (const e of impact.edges) {
  886. const key = `${e.source}->${e.target}:${e.kind}`;
  887. if (!seenEdges.has(key)) {
  888. seenEdges.add(key);
  889. mergedEdges.push(e);
  890. }
  891. }
  892. }
  893. const mergedImpact = {
  894. nodes: mergedNodes,
  895. edges: mergedEdges,
  896. roots: allMatches.nodes.map(n => n.id),
  897. };
  898. const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
  899. return this.textResult(this.truncateOutput(formatted));
  900. }
  901. /**
  902. * Handle codegraph_trace — shortest CALL PATH between two symbols.
  903. *
  904. * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
  905. * each hop annotated with file:line and the call-site line. This is the
  906. * capability grep/Read structurally cannot provide. When no static path
  907. * exists, the chain has almost certainly broken at dynamic dispatch
  908. * (callbacks, descriptors, metaclasses) — we say so and surface the start
  909. * symbol's outgoing calls so the agent bridges the one missing hop with
  910. * codegraph_node rather than blindly reading.
  911. */
  912. private async handleTrace(args: Record<string, unknown>): Promise<ToolResult> {
  913. const from = this.validateString(args.from, 'from');
  914. if (typeof from !== 'string') return from;
  915. const to = this.validateString(args.to, 'to');
  916. if (typeof to !== 'string') return to;
  917. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  918. const fromMatches = this.findAllSymbols(cg, from);
  919. if (fromMatches.nodes.length === 0) return this.textResult(`Symbol "${from}" not found in the codebase`);
  920. const toMatches = this.findAllSymbols(cg, to);
  921. if (toMatches.nodes.length === 0) return this.textResult(`Symbol "${to}" not found in the codebase`);
  922. // Trace along call edges only — a true call path. Names can map to several
  923. // nodes, so try a few from×to candidate pairs until a usable path turns up.
  924. //
  925. // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
  926. // is almost always a spurious wander through unrelated code (django's
  927. // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
  928. // the real execution flow — and a confident-but-wrong 15-hop trace is worse
  929. // than none. Over-cap paths are rejected and reported as "no direct path"
  930. // (which, on real code, means the flow breaks at dynamic dispatch).
  931. const edgeKinds: Edge['kind'][] = ['calls'];
  932. const MAX_HOPS = 7;
  933. const fromTry = fromMatches.nodes.slice(0, 3);
  934. const toTry = toMatches.nodes.slice(0, 3);
  935. let path: Array<{ node: Node; edge: Edge | null }> | null = null;
  936. let overCap: Array<{ node: Node; edge: Edge | null }> | null = null;
  937. for (const f of fromTry) {
  938. for (const t of toTry) {
  939. const p = cg.findPath(f.id, t.id, edgeKinds);
  940. if (!p || p.length <= 1) continue;
  941. if (p.length <= MAX_HOPS) { path = p; break; }
  942. if (!overCap || p.length < overCap.length) overCap = p;
  943. }
  944. if (path) break;
  945. }
  946. if (!path) {
  947. // No static path — almost always a dynamic-dispatch break. Surface the
  948. // start symbol's outgoing calls so the agent can bridge the gap.
  949. const start = fromTry[0]!;
  950. const callees = cg.getCallees(start.id).slice(0, 10)
  951. .map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
  952. const lines = [
  953. `No direct call path from "${from}" to "${to}".`,
  954. '',
  955. (overCap
  956. ? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
  957. : '') +
  958. 'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
  959. 'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
  960. `Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
  961. '(includeCode=true) — its body usually shows the dynamic call to follow next.',
  962. ];
  963. if (callees.length > 0) {
  964. lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
  965. }
  966. return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
  967. }
  968. const lines: string[] = [`## Trace: ${from} → ${to}`, '', `${path.length} hops:`, ''];
  969. // Inline the evidence each hop needs so the agent doesn't Read/Grep to get it:
  970. // the call-site source line for static calls, and — for dynamic-dispatch hops
  971. // bridged by callback synthesis — where the callback was registered. (This is
  972. // exactly what agents grepped for under a Read-0 constraint.)
  973. const fileCache = new Map<string, string[]>();
  974. for (let i = 0; i < path.length; i++) {
  975. const step = path[i]!;
  976. if (step.edge) {
  977. const synth = this.synthEdgeNote(step.edge);
  978. if (synth) {
  979. lines.push(` ↓ ${synth.label}`);
  980. if (synth.registeredAt) {
  981. const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
  982. lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
  983. }
  984. } else {
  985. // The call happens in the PREVIOUS hop's file at edge.line.
  986. const prev = path[i - 1];
  987. const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
  988. const callSrc = this.sourceLineAt(cg, ref, fileCache);
  989. lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
  990. }
  991. }
  992. lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
  993. }
  994. lines.push('', '> Each hop shows its call-site source line (and, for dynamic-dispatch hops, where the callback was registered) — no Read needed. codegraph_node a hop only for its full body.');
  995. return this.textResult(this.truncateOutput(lines.join('\n')));
  996. }
  997. /**
  998. * Describe a synthesized (dynamic-dispatch) edge for human output: how the
  999. * callback was wired up — the bridge static parsing can't see. Returns null
  1000. * for ordinary static edges. Used by trace + the node trail so a synthesized
  1001. * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
  1002. */
  1003. private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
  1004. if (!edge || edge.provenance !== 'heuristic') return null;
  1005. const m = edge.metadata as Record<string, unknown> | undefined;
  1006. const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
  1007. const at = registeredAt ? ` @${registeredAt}` : '';
  1008. if (m?.synthesizedBy === 'callback') {
  1009. const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
  1010. const field = m.field ? ` on .${String(m.field)}` : '';
  1011. return {
  1012. label: `callback — registered via ${via}${field} (dynamic dispatch)`,
  1013. compact: `dynamic: callback via ${via}${at}`,
  1014. registeredAt,
  1015. };
  1016. }
  1017. if (m?.synthesizedBy === 'event-emitter') {
  1018. const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
  1019. return {
  1020. label: `event ${ev} — emit → handler (dynamic dispatch)`,
  1021. compact: `dynamic: event ${ev}${at}`,
  1022. registeredAt,
  1023. };
  1024. }
  1025. if (m?.synthesizedBy === 'react-render') {
  1026. return {
  1027. label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
  1028. compact: `dynamic: React re-render via setState${at}`,
  1029. registeredAt,
  1030. };
  1031. }
  1032. if (m?.synthesizedBy === 'jsx-render') {
  1033. const child = m.via ? `<${String(m.via)}>` : 'a child component';
  1034. return {
  1035. label: `renders ${child} (JSX child — dynamic dispatch)`,
  1036. compact: `dynamic: renders ${child}`,
  1037. registeredAt,
  1038. };
  1039. }
  1040. return null;
  1041. }
  1042. /**
  1043. * Read one trimmed source line at "relpath:line" (relative to the project
  1044. * root). `cache` holds split file contents so a multi-hop trace reads each
  1045. * file at most once. Returns null if the file/line can't be resolved.
  1046. */
  1047. private sourceLineAt(cg: CodeGraph, ref: string | undefined, cache: Map<string, string[]>): string | null {
  1048. if (!ref) return null;
  1049. const i = ref.lastIndexOf(':');
  1050. if (i < 0) return null;
  1051. const filePath = ref.slice(0, i);
  1052. const line = parseInt(ref.slice(i + 1), 10);
  1053. if (!Number.isFinite(line) || line < 1) return null;
  1054. let fileLines = cache.get(filePath);
  1055. if (!fileLines) {
  1056. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  1057. if (!abs || !existsSync(abs)) return null;
  1058. try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
  1059. cache.set(filePath, fileLines);
  1060. }
  1061. const raw = fileLines[line - 1];
  1062. if (raw == null) return null;
  1063. const t = raw.trim();
  1064. return t.length > 160 ? t.slice(0, 157) + '…' : t;
  1065. }
  1066. /**
  1067. * Handle codegraph_explore — deep exploration in a single call
  1068. *
  1069. * Strategy: find relevant symbols via graph traversal, group by file,
  1070. * then read contiguous file sections covering all symbols per file.
  1071. * This replaces multiple codegraph_node + Read calls.
  1072. *
  1073. * Output size is adaptive to project file count via
  1074. * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
  1075. * tax on small projects while earning its keep on large ones.
  1076. */
  1077. private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
  1078. const query = this.validateString(args.query, 'query');
  1079. if (typeof query !== 'string') return query;
  1080. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1081. const projectRoot = cg.getProjectRoot();
  1082. // Resolve adaptive output budget from project size. Falls back to the
  1083. // largest-tier defaults if stats aren't available, which preserves
  1084. // pre-#185 behavior for callers that hit the rare stats failure.
  1085. let budget: ExploreOutputBudget;
  1086. try {
  1087. budget = getExploreOutputBudget(cg.getStats().fileCount);
  1088. } catch {
  1089. budget = getExploreOutputBudget(Infinity);
  1090. }
  1091. const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
  1092. // Step 1: Find relevant context with generous parameters.
  1093. // Use a large maxNodes budget — explore has its own 35k char output limit
  1094. // that prevents context bloat, so more nodes just means better coverage
  1095. // across entry points (especially for large files like Svelte components).
  1096. const subgraph = await cg.findRelevantContext(query, {
  1097. searchLimit: 8,
  1098. traversalDepth: 3,
  1099. maxNodes: 200,
  1100. minScore: 0.2,
  1101. });
  1102. if (subgraph.nodes.size === 0) {
  1103. return this.textResult(`No relevant code found for "${query}"`);
  1104. }
  1105. // Graph-aware glue: findRelevantContext builds the subgraph from name/text
  1106. // search, so a method that BRIDGES named symbols — e.g. App.tsx's
  1107. // triggerRender, which calls the named triggerUpdate — is never a search hit
  1108. // and gets missed, forcing the agent to Read the file to trace it. Pull in
  1109. // the callers/callees of the entry (root) nodes, but ONLY those that live in
  1110. // files the subgraph already surfaces (where the agent reads to fill gaps),
  1111. // so we add wiring without dragging in unrelated files. These get an
  1112. // importance boost below so they survive the per-file cluster budget.
  1113. const glueNodeIds = new Set<string>();
  1114. const subgraphFiles = new Set<string>();
  1115. for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
  1116. const GLUE_NODE_CAP = 60;
  1117. for (const rootId of subgraph.roots) {
  1118. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1119. let neighbors: Node[] = [];
  1120. try {
  1121. neighbors = [
  1122. ...cg.getCallers(rootId).map(c => c.node),
  1123. ...cg.getCallees(rootId).map(c => c.node),
  1124. ];
  1125. } catch {
  1126. continue;
  1127. }
  1128. for (const nb of neighbors) {
  1129. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1130. if (subgraph.nodes.has(nb.id)) continue;
  1131. if (!subgraphFiles.has(nb.filePath)) continue;
  1132. subgraph.nodes.set(nb.id, nb);
  1133. glueNodeIds.add(nb.id);
  1134. }
  1135. }
  1136. // Step 2: Group nodes by file, score by relevance
  1137. const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
  1138. const entryNodeIds = new Set(subgraph.roots);
  1139. // Build a set of nodes directly connected to entry points (depth 1)
  1140. const connectedToEntry = new Set<string>();
  1141. for (const edge of subgraph.edges) {
  1142. if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
  1143. if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
  1144. }
  1145. for (const node of subgraph.nodes.values()) {
  1146. // Skip import/export nodes — they add noise without information
  1147. if (node.kind === 'import' || node.kind === 'export') continue;
  1148. const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
  1149. group.nodes.push(node);
  1150. // Score: entry point nodes worth 10, directly connected worth 3, others worth 1
  1151. if (entryNodeIds.has(node.id)) {
  1152. group.score += 10;
  1153. } else if (connectedToEntry.has(node.id)) {
  1154. group.score += 3;
  1155. } else {
  1156. group.score += 1;
  1157. }
  1158. fileGroups.set(node.filePath, group);
  1159. }
  1160. // Only include files that have entry points or nodes directly connected to entry points
  1161. const relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
  1162. // Extract query terms for relevance checking
  1163. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  1164. // Sort files: highest relevance first, deprioritize low-value files
  1165. const sortedFiles = relevantFiles.sort((a, b) => {
  1166. const aPath = a[0].toLowerCase();
  1167. const bPath = b[0].toLowerCase();
  1168. // Check if any node name or file path relates to query terms
  1169. const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
  1170. const fp = filePath.toLowerCase();
  1171. if (queryTerms.some(t => fp.includes(t))) return true;
  1172. return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
  1173. };
  1174. const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
  1175. const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
  1176. if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
  1177. // Deprioritize test files, icon files, and i18n files
  1178. const isLowValue = (p: string) =>
  1179. /\/(tests?|__tests?__|spec)\//i.test(p) ||
  1180. /\bicons?\b/i.test(p) ||
  1181. /\bi18n\b/i.test(p);
  1182. const aLow = isLowValue(aPath);
  1183. const bLow = isLowValue(bPath);
  1184. if (aLow !== bLow) return aLow ? 1 : -1;
  1185. if (a[1].score !== b[1].score) return b[1].score - a[1].score;
  1186. return b[1].nodes.length - a[1].nodes.length;
  1187. });
  1188. // Step 3: Build relationship map
  1189. const lines: string[] = [
  1190. `## Exploration: ${query}`,
  1191. '',
  1192. `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
  1193. '',
  1194. ];
  1195. // Relationship map — show how symbols connect
  1196. const significantEdges = subgraph.edges.filter(e =>
  1197. e.kind !== 'contains' // skip contains — it's implied by file grouping
  1198. );
  1199. if (budget.includeRelationships && significantEdges.length > 0) {
  1200. lines.push('### Relationships');
  1201. lines.push('');
  1202. // Group edges by kind for readability
  1203. const byKind = new Map<string, Array<{ source: string; target: string }>>();
  1204. for (const edge of significantEdges) {
  1205. const sourceNode = subgraph.nodes.get(edge.source);
  1206. const targetNode = subgraph.nodes.get(edge.target);
  1207. if (!sourceNode || !targetNode) continue;
  1208. const group = byKind.get(edge.kind) || [];
  1209. group.push({ source: sourceNode.name, target: targetNode.name });
  1210. byKind.set(edge.kind, group);
  1211. }
  1212. for (const [kind, edges] of byKind) {
  1213. const cap = budget.maxEdgesPerRelationshipKind;
  1214. const shown = edges.slice(0, cap);
  1215. lines.push(`**${kind}:**`);
  1216. for (const e of shown) {
  1217. lines.push(`- ${e.source} → ${e.target}`);
  1218. }
  1219. if (edges.length > cap) {
  1220. lines.push(`- ... and ${edges.length - cap} more`);
  1221. }
  1222. lines.push('');
  1223. }
  1224. }
  1225. // Step 4: Read contiguous file sections
  1226. lines.push('### Source Code');
  1227. lines.push('');
  1228. lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
  1229. lines.push('');
  1230. let totalChars = lines.join('\n').length;
  1231. let filesIncluded = 0;
  1232. let anyFileTrimmed = false;
  1233. for (const [filePath, group] of sortedFiles) {
  1234. if (filesIncluded >= maxFiles) break;
  1235. if (totalChars > budget.maxOutputChars * 0.9) break;
  1236. const absPath = validatePathWithinRoot(projectRoot, filePath);
  1237. if (!absPath || !existsSync(absPath)) continue;
  1238. let fileContent: string;
  1239. try {
  1240. fileContent = readFileSync(absPath, 'utf-8');
  1241. } catch {
  1242. continue;
  1243. }
  1244. const fileLines = fileContent.split('\n');
  1245. const lang = group.nodes[0]?.language || '';
  1246. // Whole-small-file rule: if a relevant file is small enough to afford,
  1247. // return it ENTIRELY instead of clustering. Clustering exists to tame
  1248. // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
  1249. // lossy subset of a file the agent will just Read in full anyway — costing
  1250. // a round-trip and a re-read every later turn. Reserve clustering for files
  1251. // too big to ship whole. Still bounded by the total maxOutputChars check.
  1252. const WHOLE_FILE_MAX_LINES = 220;
  1253. const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
  1254. if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
  1255. const body = fileContent.replace(/\n+$/, '');
  1256. let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
  1257. const uniqSymbols = [...new Set(
  1258. group.nodes
  1259. .filter(n => n.kind !== 'import' && n.kind !== 'export')
  1260. .map(n => `${n.name}(${n.kind})`)
  1261. )];
  1262. const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
  1263. const omitted = uniqSymbols.length - headerNames.length;
  1264. const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
  1265. if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
  1266. const remaining = budget.maxOutputChars - totalChars - 200;
  1267. if (remaining < 500) break;
  1268. wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
  1269. anyFileTrimmed = true;
  1270. }
  1271. lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
  1272. totalChars += wholeSection.length + 200;
  1273. filesIncluded++;
  1274. continue;
  1275. }
  1276. // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
  1277. // Sort by start line, then merge overlapping/adjacent ranges (within the
  1278. // adaptive gap threshold). Include both node ranges AND edge source
  1279. // locations so template sections with component usages/calls are
  1280. // covered (not just script block symbols).
  1281. //
  1282. // Each range carries an `importance` score so we can rank clusters
  1283. // when the per-file budget forces us to drop some: entry-point nodes
  1284. // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
  1285. // bare edge-source lines 2 (less than a connected node but more than
  1286. // a peripheral one — they hint at a reference but aren't a definition).
  1287. // Container kinds whose body can span most/all of a file. When such a
  1288. // node covers most of the file we drop it from the ranges: keeping it
  1289. // would merge every method inside it into one giant cluster spanning
  1290. // the whole file, which then tail-trims down to just the container's
  1291. // opening lines (its header/declarations) and buries the methods the
  1292. // query actually asked about (#185 follow-up — Session.swift in
  1293. // Alamofire is the canonical case: the `Session` class spans ~1,400
  1294. // lines). We want the granular symbols inside, not the envelope.
  1295. const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
  1296. const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
  1297. .filter(n => n.startLine > 0 && n.endLine > 0)
  1298. // Drop whole-file envelope nodes (containers covering >50% of the file).
  1299. .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
  1300. .map(n => {
  1301. let importance = 1;
  1302. if (entryNodeIds.has(n.id)) importance = 10;
  1303. else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
  1304. else if (connectedToEntry.has(n.id)) importance = 3;
  1305. return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
  1306. });
  1307. // Add edge source locations in this file — captures template references
  1308. // (component usages, event handlers) that aren't nodes themselves.
  1309. // Query edges directly from the DB (not just the subgraph) because BFS
  1310. // traversal may have pruned template reference targets due to node budget.
  1311. const edgeLines = new Set<string>(); // dedup by "line:name"
  1312. for (const node of group.nodes) {
  1313. const outgoing = cg.getOutgoingEdges(node.id);
  1314. for (const edge of outgoing) {
  1315. if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
  1316. const key = `${edge.line}:${edge.target}`;
  1317. if (edgeLines.has(key)) continue;
  1318. edgeLines.add(key);
  1319. // Look up target name from subgraph first, fall back to edge kind
  1320. const targetNode = subgraph.nodes.get(edge.target);
  1321. const targetName = targetNode?.name ?? edge.kind;
  1322. ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
  1323. }
  1324. }
  1325. ranges.sort((a, b) => a.start - b.start);
  1326. if (ranges.length === 0) continue;
  1327. const gapThreshold = budget.gapThreshold;
  1328. const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
  1329. let current = {
  1330. start: ranges[0]!.start,
  1331. end: ranges[0]!.end,
  1332. symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
  1333. score: ranges[0]!.importance,
  1334. maxImportance: ranges[0]!.importance,
  1335. };
  1336. for (let i = 1; i < ranges.length; i++) {
  1337. const r = ranges[i]!;
  1338. if (r.start <= current.end + gapThreshold) {
  1339. current.end = Math.max(current.end, r.end);
  1340. current.symbols.push(`${r.name}(${r.kind})`);
  1341. current.score += r.importance;
  1342. current.maxImportance = Math.max(current.maxImportance, r.importance);
  1343. } else {
  1344. clusters.push(current);
  1345. current = {
  1346. start: r.start,
  1347. end: r.end,
  1348. symbols: [`${r.name}(${r.kind})`],
  1349. score: r.importance,
  1350. maxImportance: r.importance,
  1351. };
  1352. }
  1353. }
  1354. clusters.push(current);
  1355. // Build file section output from clusters, capped by per-file budget.
  1356. // The pathological case (#185): a file like Session.swift where every
  1357. // method is adjacent collapses into one cluster spanning the whole
  1358. // file, and dumping that into the agent's context is most of the
  1359. // token cost on small projects. We pick clusters in priority order
  1360. // until the per-file char cap is hit. Truly enormous single clusters
  1361. // get tail-trimmed with a marker.
  1362. const contextPadding = 3;
  1363. const withLineNumbers = exploreLineNumbersEnabled();
  1364. const buildSection = (c: { start: number; end: number }): string => {
  1365. const startIdx = Math.max(0, c.start - 1 - contextPadding);
  1366. const endIdx = Math.min(fileLines.length, c.end + contextPadding);
  1367. const slice = fileLines.slice(startIdx, endIdx).join('\n');
  1368. // startIdx is 0-based, so the slice's first line is line startIdx + 1.
  1369. return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
  1370. };
  1371. // Language-neutral separator (no `//` — not a comment in Python, Ruby,
  1372. // etc.). With line numbers on, the line-number jump also signals the gap.
  1373. const GAP_MARKER = '\n\n... (gap) ...\n\n';
  1374. // Rank clusters for inclusion under the per-file cap. Entry-point
  1375. // clusters come first: a cluster containing a query entry point
  1376. // (importance 10) must outrank a dense block of mere declarations,
  1377. // otherwise on a large file like Session.swift the top-of-file class
  1378. // header + property list (many adjacent low-importance nodes, high
  1379. // density) wins the budget and buries the actual methods the query
  1380. // asked about (perform/didCreateURLRequest/task live deep in the
  1381. // file). Within the same importance tier, prefer density (score per
  1382. // line) so we still favor focused clusters over sprawling ones, then
  1383. // smaller span as a cheap-to-include tiebreak.
  1384. const rankedClusters = clusters
  1385. .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
  1386. .sort((a, b) => {
  1387. if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
  1388. const densityA = a.c.score / a.span;
  1389. const densityB = b.c.score / b.span;
  1390. if (densityB !== densityA) return densityB - densityA;
  1391. if (b.c.score !== a.c.score) return b.c.score - a.c.score;
  1392. return a.span - b.span;
  1393. });
  1394. const chosenIndices = new Set<number>();
  1395. let projectedChars = 0;
  1396. for (const rc of rankedClusters) {
  1397. const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
  1398. // Always take the top-ranked cluster, even if oversize, so we don't
  1399. // return an empty file section (agent would then re-Read the file,
  1400. // negating the savings).
  1401. if (chosenIndices.size === 0) {
  1402. chosenIndices.add(rc.idx);
  1403. projectedChars += sectionLen;
  1404. continue;
  1405. }
  1406. if (projectedChars + sectionLen > budget.maxCharsPerFile) continue;
  1407. chosenIndices.add(rc.idx);
  1408. projectedChars += sectionLen;
  1409. }
  1410. // Emit chosen clusters in source order so the file reads top-to-bottom.
  1411. let fileSection = '';
  1412. const allSymbols: string[] = [];
  1413. let fileTrimmed = false;
  1414. for (let i = 0; i < clusters.length; i++) {
  1415. if (!chosenIndices.has(i)) continue;
  1416. const cluster = clusters[i]!;
  1417. const section = buildSection(cluster);
  1418. if (fileSection.length > 0) fileSection += GAP_MARKER;
  1419. fileSection += section;
  1420. allSymbols.push(...cluster.symbols);
  1421. }
  1422. // If a single chosen cluster is still oversize (long monolithic
  1423. // function), tail-trim it. Better one trimmed view than nothing.
  1424. if (fileSection.length > budget.maxCharsPerFile) {
  1425. fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
  1426. fileTrimmed = true;
  1427. }
  1428. if (chosenIndices.size < clusters.length || fileTrimmed) {
  1429. anyFileTrimmed = true;
  1430. }
  1431. // Dedupe + cap the symbols list shown in the per-file header. Some
  1432. // files (Session.swift in Alamofire) produced 3.4KB symbol lists
  1433. // from cluster scoring + edge-source lines, dwarfing the per-file
  1434. // body cap. Show top names by frequency, with a "+N more" tail.
  1435. const symbolCounts = new Map<string, number>();
  1436. for (const s of allSymbols) {
  1437. symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
  1438. }
  1439. const sortedSymbols = [...symbolCounts.entries()]
  1440. .sort((a, b) => b[1] - a[1])
  1441. .map(([name]) => name);
  1442. const headerCap = budget.maxSymbolsInFileHeader;
  1443. const headerSymbols = sortedSymbols.slice(0, headerCap);
  1444. const omittedCount = sortedSymbols.length - headerSymbols.length;
  1445. const headerSuffix = omittedCount > 0
  1446. ? `${headerSymbols.join(', ')}, +${omittedCount} more`
  1447. : headerSymbols.join(', ');
  1448. const fileHeader = `#### ${filePath} — ${headerSuffix}`;
  1449. // Respect the total output cap on a file-by-file basis.
  1450. if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
  1451. const remaining = budget.maxOutputChars - totalChars - 200;
  1452. if (remaining < 500) break;
  1453. const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
  1454. lines.push(fileHeader);
  1455. lines.push('');
  1456. lines.push('```' + lang);
  1457. lines.push(trimmed);
  1458. lines.push('```');
  1459. lines.push('');
  1460. totalChars += trimmed.length + 200;
  1461. filesIncluded++;
  1462. anyFileTrimmed = true;
  1463. break;
  1464. }
  1465. lines.push(fileHeader);
  1466. lines.push('');
  1467. lines.push('```' + lang);
  1468. lines.push(fileSection);
  1469. lines.push('```');
  1470. lines.push('');
  1471. totalChars += fileSection.length + 200;
  1472. filesIncluded++;
  1473. }
  1474. // Add remaining files as references (from both relevant and peripheral files).
  1475. // Small projects (per budget) skip this — the relevant story already fits
  1476. // in the source section, and a trailing pointer list is pure overhead.
  1477. if (budget.includeAdditionalFiles) {
  1478. const remainingRelevant = sortedFiles.slice(filesIncluded);
  1479. const peripheralFiles = [...fileGroups.entries()]
  1480. .filter(([, group]) => group.score < 3)
  1481. .sort((a, b) => b[1].score - a[1].score);
  1482. const remainingFiles = [...remainingRelevant, ...peripheralFiles];
  1483. if (remainingFiles.length > 0) {
  1484. lines.push('### Not shown above — explore these names for their source');
  1485. lines.push('');
  1486. for (const [filePath, group] of remainingFiles.slice(0, 10)) {
  1487. const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  1488. lines.push(`- ${filePath}: ${symbols}`);
  1489. }
  1490. if (remainingFiles.length > 10) {
  1491. lines.push(`- ... and ${remainingFiles.length - 10} more files`);
  1492. }
  1493. }
  1494. }
  1495. // Add completeness signal so agents know they don't need to re-read these files.
  1496. // On small projects the budget gates this off — but if we actually had to
  1497. // trim or drop clusters, surface a brief note so the agent knows it can
  1498. // still Read for more detail.
  1499. if (budget.includeCompletenessSignal) {
  1500. lines.push('');
  1501. lines.push('---');
  1502. lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
  1503. } else if (anyFileTrimmed) {
  1504. lines.push('');
  1505. lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
  1506. }
  1507. // Add explore budget note based on project size
  1508. if (budget.includeBudgetNote) {
  1509. try {
  1510. const stats = cg.getStats();
  1511. const callBudget = getExploreBudget(stats.fileCount);
  1512. lines.push('');
  1513. lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
  1514. } catch {
  1515. // Stats unavailable — skip budget note
  1516. }
  1517. }
  1518. // Hard-cap to the adaptive budget. The per-file loop bounds the source
  1519. // sections, but the relationship map, additional-files list, and
  1520. // completeness/budget notes can still push the assembled output past
  1521. // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
  1522. // payload persists in the agent's context and is re-read as cache-input
  1523. // on every subsequent turn, so the overrun is paid many times over.
  1524. const output = lines.join('\n');
  1525. if (output.length > budget.maxOutputChars) {
  1526. const cut = output.slice(0, budget.maxOutputChars);
  1527. const lastNewline = cut.lastIndexOf('\n');
  1528. const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
  1529. return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
  1530. }
  1531. return this.textResult(output);
  1532. }
  1533. /**
  1534. * Handle codegraph_node
  1535. */
  1536. private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
  1537. const symbol = this.validateString(args.symbol, 'symbol');
  1538. if (typeof symbol !== 'string') return symbol;
  1539. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1540. // Default to false to minimize context usage
  1541. const includeCode = args.includeCode === true;
  1542. const match = this.findSymbol(cg, symbol);
  1543. if (!match) {
  1544. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  1545. }
  1546. let code: string | null = null;
  1547. let outline: string | null = null;
  1548. if (includeCode) {
  1549. // For container symbols (class/interface/struct/…), the full body is the
  1550. // sum of every method body — a wall of source (e.g. a 10k-char class)
  1551. // that bloats context and is rarely needed in full. Return a structural
  1552. // outline (members + signatures + line numbers) instead; the agent can
  1553. // Read or codegraph_node a specific method for its body. Leaf symbols
  1554. // (function/method/etc.) return their full body as before.
  1555. if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
  1556. outline = this.buildContainerOutline(cg, match.node);
  1557. }
  1558. if (!outline) {
  1559. code = await cg.getCode(match.node.id);
  1560. }
  1561. }
  1562. const trail = this.formatTrail(cg, match.node);
  1563. const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
  1564. return this.textResult(this.truncateOutput(formatted));
  1565. }
  1566. /**
  1567. * Build the "trail" for a symbol: its direct callees (what it calls) and
  1568. * callers (what calls it), each with file:line — so codegraph_node doubles as
  1569. * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
  1570. * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
  1571. * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
  1572. * dynamic dispatch the static graph couldn't resolve — that absence is itself
  1573. * a signal (read that one hop) rather than a dead end.
  1574. */
  1575. private formatTrail(cg: CodeGraph, node: Node): string {
  1576. const TRAIL_CAP = 12;
  1577. const fmt = (e: { node: Node; edge: Edge }) => {
  1578. const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
  1579. const synth = this.synthEdgeNote(e.edge);
  1580. return synth ? `${base} [${synth.compact}]` : base;
  1581. };
  1582. const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
  1583. const seen = new Set<string>([node.id]);
  1584. const out: Array<{ node: Node; edge: Edge }> = [];
  1585. for (const e of edges) {
  1586. if (seen.has(e.node.id)) continue;
  1587. seen.add(e.node.id);
  1588. out.push(e);
  1589. }
  1590. return out;
  1591. };
  1592. const callees = collect(cg.getCallees(node.id));
  1593. const callers = collect(cg.getCallers(node.id));
  1594. if (callees.length === 0 && callers.length === 0) return '';
  1595. const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
  1596. if (callees.length > 0) {
  1597. lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
  1598. }
  1599. if (callers.length > 0) {
  1600. lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
  1601. }
  1602. return lines.join('\n');
  1603. }
  1604. /**
  1605. * Handle codegraph_status
  1606. */
  1607. private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
  1608. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1609. const stats = cg.getStats();
  1610. const lines: string[] = [
  1611. '## CodeGraph Status',
  1612. '',
  1613. `**Files indexed:** ${stats.fileCount}`,
  1614. `**Total nodes:** ${stats.nodeCount}`,
  1615. `**Total edges:** ${stats.edgeCount}`,
  1616. `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
  1617. ];
  1618. // Surface the active SQLite backend (node:sqlite, Node's built-in real
  1619. // SQLite — full WAL + FTS5, no native build).
  1620. lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
  1621. // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
  1622. // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
  1623. // everywhere, so a non-wal mode means the filesystem can't (network/
  1624. // virtualized mounts, WSL2 /mnt). See issue #238.
  1625. const journalMode = cg.getJournalMode();
  1626. if (journalMode === 'wal') {
  1627. lines.push(`**Journal mode:** wal (concurrent reads safe)`);
  1628. } else {
  1629. lines.push(
  1630. `**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
  1631. `can block on a concurrent write (WAL appears unsupported on this filesystem)`
  1632. );
  1633. }
  1634. lines.push('', '### Nodes by Kind:');
  1635. for (const [kind, count] of Object.entries(stats.nodesByKind)) {
  1636. if ((count as number) > 0) {
  1637. lines.push(`- ${kind}: ${count}`);
  1638. }
  1639. }
  1640. lines.push('', '### Languages:');
  1641. for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
  1642. if ((count as number) > 0) {
  1643. lines.push(`- ${lang}: ${count}`);
  1644. }
  1645. }
  1646. return this.textResult(lines.join('\n'));
  1647. }
  1648. /**
  1649. * Handle codegraph_files - get project file structure from the index
  1650. */
  1651. private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
  1652. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1653. const pathFilter = args.path as string | undefined;
  1654. const pattern = args.pattern as string | undefined;
  1655. const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
  1656. const includeMetadata = args.includeMetadata !== false;
  1657. const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
  1658. // Get all files from the index
  1659. const allFiles = cg.getFiles();
  1660. if (allFiles.length === 0) {
  1661. return this.textResult('No files indexed. Run `codegraph index` first.');
  1662. }
  1663. // Filter by path prefix
  1664. let files = pathFilter
  1665. ? allFiles.filter(f => f.path.startsWith(pathFilter) || f.path.startsWith('./' + pathFilter))
  1666. : allFiles;
  1667. // Filter by glob pattern
  1668. if (pattern) {
  1669. const regex = this.globToRegex(pattern);
  1670. files = files.filter(f => regex.test(f.path));
  1671. }
  1672. if (files.length === 0) {
  1673. return this.textResult(`No files found matching the criteria.`);
  1674. }
  1675. // Format output
  1676. let output: string;
  1677. switch (format) {
  1678. case 'flat':
  1679. output = this.formatFilesFlat(files, includeMetadata);
  1680. break;
  1681. case 'grouped':
  1682. output = this.formatFilesGrouped(files, includeMetadata);
  1683. break;
  1684. case 'tree':
  1685. default:
  1686. output = this.formatFilesTree(files, includeMetadata, maxDepth);
  1687. break;
  1688. }
  1689. return this.textResult(this.truncateOutput(output));
  1690. }
  1691. /**
  1692. * Convert glob pattern to regex
  1693. */
  1694. private globToRegex(pattern: string): RegExp {
  1695. const escaped = pattern
  1696. .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
  1697. .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
  1698. .replace(/\*/g, '[^/]*') // * matches anything except /
  1699. .replace(/\?/g, '[^/]') // ? matches single char except /
  1700. .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
  1701. return new RegExp(escaped);
  1702. }
  1703. /**
  1704. * Format files as a flat list
  1705. */
  1706. private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1707. const lines: string[] = [`## Files (${files.length})`, ''];
  1708. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  1709. if (includeMetadata) {
  1710. lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
  1711. } else {
  1712. lines.push(`- ${file.path}`);
  1713. }
  1714. }
  1715. return lines.join('\n');
  1716. }
  1717. /**
  1718. * Format files grouped by language
  1719. */
  1720. private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1721. const byLang = new Map<string, typeof files>();
  1722. for (const file of files) {
  1723. const existing = byLang.get(file.language) || [];
  1724. existing.push(file);
  1725. byLang.set(file.language, existing);
  1726. }
  1727. const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
  1728. // Sort languages by file count (descending)
  1729. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  1730. for (const [lang, langFiles] of sortedLangs) {
  1731. lines.push(`### ${lang} (${langFiles.length})`);
  1732. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  1733. if (includeMetadata) {
  1734. lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
  1735. } else {
  1736. lines.push(`- ${file.path}`);
  1737. }
  1738. }
  1739. lines.push('');
  1740. }
  1741. return lines.join('\n');
  1742. }
  1743. /**
  1744. * Format files as a tree structure
  1745. */
  1746. private formatFilesTree(
  1747. files: { path: string; language: string; nodeCount: number }[],
  1748. includeMetadata: boolean,
  1749. maxDepth?: number
  1750. ): string {
  1751. // Build tree structure
  1752. interface TreeNode {
  1753. name: string;
  1754. children: Map<string, TreeNode>;
  1755. file?: { language: string; nodeCount: number };
  1756. }
  1757. const root: TreeNode = { name: '', children: new Map() };
  1758. for (const file of files) {
  1759. const parts = file.path.split('/');
  1760. let current = root;
  1761. for (let i = 0; i < parts.length; i++) {
  1762. const part = parts[i];
  1763. if (!part) continue;
  1764. if (!current.children.has(part)) {
  1765. current.children.set(part, { name: part, children: new Map() });
  1766. }
  1767. current = current.children.get(part)!;
  1768. // If this is the last part, it's a file
  1769. if (i === parts.length - 1) {
  1770. current.file = { language: file.language, nodeCount: file.nodeCount };
  1771. }
  1772. }
  1773. }
  1774. // Render tree
  1775. const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
  1776. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  1777. if (maxDepth !== undefined && depth > maxDepth) return;
  1778. const connector = isLast ? '└── ' : '├── ';
  1779. const childPrefix = isLast ? ' ' : '│ ';
  1780. if (node.name) {
  1781. let line = prefix + connector + node.name;
  1782. if (node.file && includeMetadata) {
  1783. line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
  1784. }
  1785. lines.push(line);
  1786. }
  1787. const children = [...node.children.values()];
  1788. // Sort: directories first, then files, both alphabetically
  1789. children.sort((a, b) => {
  1790. const aIsDir = a.children.size > 0 && !a.file;
  1791. const bIsDir = b.children.size > 0 && !b.file;
  1792. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  1793. return a.name.localeCompare(b.name);
  1794. });
  1795. for (let i = 0; i < children.length; i++) {
  1796. const child = children[i]!;
  1797. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  1798. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  1799. }
  1800. };
  1801. renderNode(root, '', true, 0);
  1802. return lines.join('\n');
  1803. }
  1804. // =========================================================================
  1805. // Symbol resolution helpers
  1806. // =========================================================================
  1807. /**
  1808. * Find a symbol by name, handling disambiguation when multiple matches exist.
  1809. * Returns the best match and a note about alternatives if any.
  1810. */
  1811. /**
  1812. * Check if a node matches a symbol query.
  1813. *
  1814. * Accepts simple names (`run`) and three flavors of qualifier:
  1815. * - dotted `Session.request` (TS/JS/Python)
  1816. * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
  1817. * - slash `configurator/stage_apply` (path-ish)
  1818. *
  1819. * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
  1820. * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
  1821. * the canonical `crate::module::symbol` form resolves.
  1822. *
  1823. * Resolution order, last part must always equal `node.name`:
  1824. * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
  1825. * where the extractor builds the qualified name from the AST stack)
  1826. * 2. File-path containment (handles file-derived modules in Rust/
  1827. * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
  1828. */
  1829. private matchesSymbol(node: Node, symbol: string): boolean {
  1830. // Simple name match
  1831. if (node.name === symbol) return true;
  1832. // File basename match (e.g., "product-card" matches "product-card.liquid")
  1833. if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
  1834. // Qualified-name lookups: split on any supported separator. `\w` keeps
  1835. // identifier chars (incl. `_`) intact; everything else is treated as
  1836. // a separator we tolerate.
  1837. if (!/[.\/]|::/.test(symbol)) return false;
  1838. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  1839. if (parts.length < 2) return false;
  1840. const lastPart = parts[parts.length - 1]!;
  1841. if (node.name !== lastPart) return false;
  1842. // Stage 1: qualified-name suffix match. The extractor joins the
  1843. // semantic hierarchy with `::`, so `Session.request` and
  1844. // `Session::request` both become `Session::request` here.
  1845. const colonSuffix = parts.join('::');
  1846. if (node.qualifiedName.includes(colonSuffix)) return true;
  1847. // Stage 2: file-path containment. Rust modules and Python packages
  1848. // are not in `qualifiedName` — they're encoded in the file path. So
  1849. // `stage_apply::run` matches a `run` in any file whose path
  1850. // contains a `stage_apply` segment (with or without an extension).
  1851. //
  1852. // Filter out Rust path prefixes that have no file-system equivalent.
  1853. const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
  1854. if (containerHints.length === 0) return false;
  1855. const segments = node.filePath.split('/').filter((s) => s.length > 0);
  1856. return containerHints.every((hint) =>
  1857. segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
  1858. );
  1859. }
  1860. private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
  1861. // Use higher limit for qualified lookups (e.g., "Session.request",
  1862. // "stage_apply::run") since the target may rank lower in FTS when
  1863. // there are many partial matches across the qualifier parts.
  1864. const isQualified = /[.\/]|::/.test(symbol);
  1865. const limit = isQualified ? 50 : 10;
  1866. let results = cg.searchNodes(symbol, { limit });
  1867. // FTS strips colons as a special char, so `stage_apply::run` searches
  1868. // for the literal `stage_applyrun` and finds nothing. Re-search by
  1869. // the bare last part and let `matchesSymbol` filter by qualifier.
  1870. if (isQualified && results.length === 0) {
  1871. const tail = lastQualifierPart(symbol);
  1872. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
  1873. }
  1874. if (results.length === 0 || !results[0]) {
  1875. return null;
  1876. }
  1877. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  1878. if (exactMatches.length === 1) {
  1879. return { node: exactMatches[0]!.node, note: '' };
  1880. }
  1881. if (exactMatches.length > 1) {
  1882. // Multiple exact matches - pick first, note the others
  1883. const picked = exactMatches[0]!.node;
  1884. const others = exactMatches.slice(1).map(r =>
  1885. `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
  1886. );
  1887. const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
  1888. return { node: picked, note };
  1889. }
  1890. // No exact match. For qualified lookups, don't silently fall back
  1891. // to a fuzzy result — the user typed a specific qualifier, and
  1892. // resolving `stage_apply::nonexistent_fn` to the unrelated
  1893. // `stage_apply.rs` file would be actively misleading (#173).
  1894. if (isQualified) return null;
  1895. return { node: results[0]!.node, note: '' };
  1896. }
  1897. /**
  1898. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  1899. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  1900. */
  1901. private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  1902. let results = cg.searchNodes(symbol, { limit: 50 });
  1903. // Mirror the fallback in `findSymbol` for qualified queries — FTS
  1904. // strips colons, so a module-qualified lookup needs a second pass
  1905. // by the bare last part.
  1906. if (results.length === 0 && /[.\/]|::/.test(symbol)) {
  1907. const tail = lastQualifierPart(symbol);
  1908. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
  1909. }
  1910. if (results.length === 0) {
  1911. return { nodes: [], note: '' };
  1912. }
  1913. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  1914. if (exactMatches.length <= 1) {
  1915. const node = exactMatches[0]?.node ?? results[0]!.node;
  1916. return { nodes: [node], note: '' };
  1917. }
  1918. const locations = exactMatches.map(r =>
  1919. `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
  1920. );
  1921. const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
  1922. return { nodes: exactMatches.map(r => r.node), note };
  1923. }
  1924. /**
  1925. * Truncate output if it exceeds the maximum length
  1926. */
  1927. private truncateOutput(text: string): string {
  1928. if (text.length <= MAX_OUTPUT_LENGTH) return text;
  1929. const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
  1930. const lastNewline = truncated.lastIndexOf('\n');
  1931. const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
  1932. return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
  1933. }
  1934. // =========================================================================
  1935. // Formatting helpers (compact by default to reduce context usage)
  1936. // =========================================================================
  1937. private formatSearchResults(results: SearchResult[]): string {
  1938. const lines: string[] = [`## Search Results (${results.length} found)`, ''];
  1939. for (const result of results) {
  1940. const { node } = result;
  1941. const location = node.startLine ? `:${node.startLine}` : '';
  1942. // Compact format: one line per result with key info
  1943. lines.push(`### ${node.name} (${node.kind})`);
  1944. lines.push(`${node.filePath}${location}`);
  1945. if (node.signature) lines.push(`\`${node.signature}\``);
  1946. lines.push('');
  1947. }
  1948. return lines.join('\n');
  1949. }
  1950. private formatNodeList(nodes: Node[], title: string): string {
  1951. const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
  1952. for (const node of nodes) {
  1953. const location = node.startLine ? `:${node.startLine}` : '';
  1954. // Compact: just name, kind, location
  1955. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
  1956. }
  1957. return lines.join('\n');
  1958. }
  1959. private formatImpact(symbol: string, impact: Subgraph): string {
  1960. const nodeCount = impact.nodes.size;
  1961. // Compact format: just list affected symbols grouped by file
  1962. const lines: string[] = [
  1963. `## Impact: "${symbol}" affects ${nodeCount} symbols`,
  1964. '',
  1965. ];
  1966. // Group by file
  1967. const byFile = new Map<string, Node[]>();
  1968. for (const node of impact.nodes.values()) {
  1969. const existing = byFile.get(node.filePath) || [];
  1970. existing.push(node);
  1971. byFile.set(node.filePath, existing);
  1972. }
  1973. for (const [file, nodes] of byFile) {
  1974. lines.push(`**${file}:**`);
  1975. // Compact: inline list
  1976. const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  1977. lines.push(nodeList);
  1978. lines.push('');
  1979. }
  1980. return lines.join('\n');
  1981. }
  1982. /**
  1983. * Build a compact structural outline of a container symbol from its
  1984. * indexed children (methods, fields, properties, …) — name, kind,
  1985. * line number, and signature — so the agent gets the shape of a class
  1986. * without the full source of every method. Returns '' when the container
  1987. * has no indexed children, so the caller can fall back to full source.
  1988. */
  1989. private buildContainerOutline(cg: CodeGraph, node: Node): string {
  1990. const children = cg.getChildren(node.id)
  1991. .filter(c => c.kind !== 'import' && c.kind !== 'export')
  1992. .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
  1993. if (children.length === 0) return '';
  1994. const lines = [`**Members (${children.length}):**`, ''];
  1995. for (const c of children) {
  1996. const loc = c.startLine ? `:${c.startLine}` : '';
  1997. const sig = c.signature ? ` — \`${c.signature}\`` : '';
  1998. lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
  1999. }
  2000. return lines.join('\n');
  2001. }
  2002. private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
  2003. const location = node.startLine ? `:${node.startLine}` : '';
  2004. const lines: string[] = [
  2005. `## ${node.name} (${node.kind})`,
  2006. '',
  2007. `**Location:** ${node.filePath}${location}`,
  2008. ];
  2009. if (node.signature) {
  2010. lines.push(`**Signature:** \`${node.signature}\``);
  2011. }
  2012. // Only include docstring if it's short and useful
  2013. if (node.docstring && node.docstring.length < 200) {
  2014. lines.push('', node.docstring);
  2015. }
  2016. if (outline) {
  2017. lines.push('', outline, '',
  2018. `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
  2019. } else if (code) {
  2020. lines.push('', '```' + node.language, code, '```');
  2021. }
  2022. return lines.join('\n');
  2023. }
  2024. private formatTaskContext(context: TaskContext): string {
  2025. return context.summary || 'No context found';
  2026. }
  2027. private textResult(text: string): ToolResult {
  2028. return {
  2029. content: [{ type: 'text', text }],
  2030. };
  2031. }
  2032. private errorResult(message: string): ToolResult {
  2033. return {
  2034. content: [{ type: 'text', text: `Error: ${message}` }],
  2035. isError: true,
  2036. };
  2037. }
  2038. }