tools.ts 101 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469
  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 and its body inlined, plus the outgoing calls of the destination itself) 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. * Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
  538. * env var (comma-separated short names, e.g. "trace,search,node,context").
  539. * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
  540. * trim the tool surface without rebuilding the client config; the ablated
  541. * tool is then truly absent from ListTools rather than merely denied on call.
  542. * Matching is on the short form, so "trace" and "codegraph_trace" both work.
  543. */
  544. private toolAllowlist(): Set<string> | null {
  545. const raw = process.env.CODEGRAPH_MCP_TOOLS;
  546. if (!raw || !raw.trim()) return null;
  547. const short = (s: string) => s.trim().replace(/^codegraph_/, '');
  548. const set = new Set(raw.split(',').map(short).filter(Boolean));
  549. return set.size ? set : null;
  550. }
  551. /** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
  552. private isToolAllowed(name: string): boolean {
  553. const allow = this.toolAllowlist();
  554. return !allow || allow.has(name.replace(/^codegraph_/, ''));
  555. }
  556. /**
  557. * Get tool definitions with dynamic descriptions based on project size.
  558. * The codegraph_explore tool description includes a budget recommendation
  559. * scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
  560. * allowlist so a trimmed surface is reflected in ListTools.
  561. */
  562. getTools(): ToolDefinition[] {
  563. const allow = this.toolAllowlist();
  564. const visible = allow
  565. ? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
  566. : tools;
  567. if (!this.cg) return visible;
  568. try {
  569. const stats = this.cg.getStats();
  570. const budget = getExploreBudget(stats.fileCount);
  571. return visible.map(tool => {
  572. if (tool.name === 'codegraph_explore') {
  573. return {
  574. ...tool,
  575. description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
  576. };
  577. }
  578. return tool;
  579. });
  580. } catch {
  581. return visible;
  582. }
  583. }
  584. /**
  585. * Get CodeGraph instance for a project
  586. *
  587. * If projectPath is provided, opens that project's CodeGraph (cached).
  588. * Otherwise returns the default CodeGraph instance.
  589. *
  590. * Walks up parent directories to find the nearest .codegraph/ folder,
  591. * similar to how git finds .git/ directories.
  592. */
  593. private getCodeGraph(projectPath?: string): CodeGraph {
  594. if (!projectPath) {
  595. if (!this.cg) {
  596. const searched = this.defaultProjectHint ?? process.cwd();
  597. throw new Error(
  598. 'No CodeGraph project is loaded for this session.\n' +
  599. `Searched for a .codegraph/ directory starting from: ${searched}\n` +
  600. 'The index is likely fine — this is a working-directory detection issue: ' +
  601. "the MCP client launched the server outside your project and didn't report the " +
  602. 'workspace root. Fix it either way:\n' +
  603. ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
  604. ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
  605. );
  606. }
  607. return this.cg;
  608. }
  609. // Check cache first (using original path as key)
  610. if (this.projectCache.has(projectPath)) {
  611. return this.projectCache.get(projectPath)!;
  612. }
  613. // Reject sensitive system directories before opening. Only validate a
  614. // path that actually exists — a nested or not-yet-created sub-path of a
  615. // real project must still be allowed to resolve UP to its .codegraph/
  616. // root below (issue #238), so we don't run the existence-checking
  617. // validator on paths that are meant to walk up.
  618. if (existsSync(projectPath)) {
  619. const pathError = validateProjectPath(projectPath);
  620. if (pathError) {
  621. throw new Error(pathError);
  622. }
  623. }
  624. // Walk up parent directories to find nearest .codegraph/
  625. const resolvedRoot = findNearestCodeGraphRoot(projectPath);
  626. if (!resolvedRoot) {
  627. throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
  628. }
  629. // If the path resolves to the default project, reuse the already-open
  630. // default instance rather than opening a SECOND connection to the same DB.
  631. // A duplicate connection serializes reads against the watcher's auto-sync
  632. // writes; on the wasm backend (no WAL) that surfaces as intermittent
  633. // "database is locked" on concurrent tool calls. See issue #238. Deliberately
  634. // not cached under projectPath — the server owns and closes the default
  635. // instance, so routing it through projectCache.closeAll() would double-close it.
  636. if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
  637. return this.cg;
  638. }
  639. // Check if we already have this resolved root cached (different path, same project)
  640. if (this.projectCache.has(resolvedRoot)) {
  641. const cg = this.projectCache.get(resolvedRoot)!;
  642. // Cache under original path too for faster future lookups
  643. this.projectCache.set(projectPath, cg);
  644. return cg;
  645. }
  646. // Open and cache under both paths
  647. const cg = CodeGraph.openSync(resolvedRoot);
  648. this.projectCache.set(resolvedRoot, cg);
  649. if (projectPath !== resolvedRoot) {
  650. this.projectCache.set(projectPath, cg);
  651. }
  652. return cg;
  653. }
  654. /**
  655. * Close all cached project connections
  656. */
  657. closeAll(): void {
  658. for (const cg of this.projectCache.values()) {
  659. cg.close();
  660. }
  661. this.projectCache.clear();
  662. }
  663. /**
  664. * Validate that a value is a non-empty string within length bounds.
  665. *
  666. * The `maxLength` cap protects against MCP clients that ship huge
  667. * payloads (10MB+ query strings either by accident or maliciously).
  668. * Without this, a single oversized input can pin the FTS5 index or
  669. * exhaust memory before any real work runs.
  670. */
  671. private validateString(
  672. value: unknown,
  673. name: string,
  674. maxLength: number = MAX_INPUT_LENGTH
  675. ): string | ToolResult {
  676. if (typeof value !== 'string' || value.length === 0) {
  677. return this.errorResult(`${name} must be a non-empty string`);
  678. }
  679. if (value.length > maxLength) {
  680. return this.errorResult(
  681. `${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`
  682. );
  683. }
  684. return value;
  685. }
  686. /**
  687. * Validate an optional path-like string input. Returns the value if
  688. * valid (or undefined), or a ToolResult with the error.
  689. */
  690. private validateOptionalPath(
  691. value: unknown,
  692. name: string
  693. ): string | undefined | ToolResult {
  694. if (value === undefined || value === null) return undefined;
  695. if (typeof value !== 'string') {
  696. return this.errorResult(`${name} must be a string`);
  697. }
  698. if (value.length > MAX_PATH_LENGTH) {
  699. return this.errorResult(
  700. `${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`
  701. );
  702. }
  703. return value;
  704. }
  705. /**
  706. * Execute a tool by name
  707. */
  708. async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  709. try {
  710. // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
  711. // surface rejects ablated tools defensively even if a client cached them.
  712. if (!this.isToolAllowed(toolName)) {
  713. return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
  714. }
  715. // Cross-cutting input validation. All tools accept an optional
  716. // `projectPath` and most accept either `query`, `task`, or
  717. // `symbol` — bound their lengths centrally so individual handlers
  718. // can stay focused on tool-specific logic.
  719. const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
  720. if (typeof pathCheck === 'object' && pathCheck !== undefined) {
  721. return pathCheck;
  722. }
  723. // The `path` and `pattern` properties used by codegraph_files are
  724. // also path-shaped — apply the same cap.
  725. if (args.path !== undefined) {
  726. const check = this.validateOptionalPath(args.path, 'path');
  727. if (typeof check === 'object' && check !== undefined) return check;
  728. }
  729. if (args.pattern !== undefined) {
  730. const check = this.validateOptionalPath(args.pattern, 'pattern');
  731. if (typeof check === 'object' && check !== undefined) return check;
  732. }
  733. switch (toolName) {
  734. case 'codegraph_search':
  735. return await this.handleSearch(args);
  736. case 'codegraph_context':
  737. return await this.handleContext(args);
  738. case 'codegraph_callers':
  739. return await this.handleCallers(args);
  740. case 'codegraph_callees':
  741. return await this.handleCallees(args);
  742. case 'codegraph_impact':
  743. return await this.handleImpact(args);
  744. case 'codegraph_explore':
  745. return await this.handleExplore(args);
  746. case 'codegraph_node':
  747. return await this.handleNode(args);
  748. case 'codegraph_status':
  749. return await this.handleStatus(args);
  750. case 'codegraph_files':
  751. return await this.handleFiles(args);
  752. case 'codegraph_trace':
  753. return await this.handleTrace(args);
  754. default:
  755. return this.errorResult(`Unknown tool: ${toolName}`);
  756. }
  757. } catch (err) {
  758. return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
  759. }
  760. }
  761. /**
  762. * Handle codegraph_search
  763. */
  764. private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
  765. const query = this.validateString(args.query, 'query');
  766. if (typeof query !== 'string') return query;
  767. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  768. const kind = args.kind as string | undefined;
  769. const rawLimit = Number(args.limit) || 10;
  770. const limit = clamp(rawLimit, 1, 100);
  771. const results = cg.searchNodes(query, {
  772. limit,
  773. kinds: kind ? [kind as NodeKind] : undefined,
  774. });
  775. if (results.length === 0) {
  776. return this.textResult(`No results found for "${query}"`);
  777. }
  778. const formatted = this.formatSearchResults(results);
  779. return this.textResult(this.truncateOutput(formatted));
  780. }
  781. /**
  782. * Handle codegraph_context
  783. */
  784. private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
  785. const task = this.validateString(args.task, 'task');
  786. if (typeof task !== 'string') return task;
  787. // Mark session as consulted (enables Grep/Glob/Bash)
  788. const sessionId = process.env.CLAUDE_SESSION_ID;
  789. if (sessionId) {
  790. markSessionConsulted(sessionId);
  791. }
  792. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  793. const maxNodes = (args.maxNodes as number) || 20;
  794. const includeCode = args.includeCode !== false;
  795. const context = await cg.buildContext(task, {
  796. maxNodes,
  797. includeCode,
  798. format: 'markdown',
  799. });
  800. // Detect if this looks like a feature request (vs bug fix or exploration)
  801. const isFeatureQuery = this.looksLikeFeatureRequest(task);
  802. const reminder = isFeatureQuery
  803. ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
  804. : '';
  805. // buildContext returns string when format is 'markdown'
  806. if (typeof context === 'string') {
  807. return this.textResult(this.truncateOutput(context + reminder));
  808. }
  809. // If it returns TaskContext, format it
  810. return this.textResult(this.truncateOutput(this.formatTaskContext(context) + reminder));
  811. }
  812. /**
  813. * Heuristic to detect if a query looks like a feature request
  814. */
  815. private looksLikeFeatureRequest(task: string): boolean {
  816. const featureKeywords = [
  817. 'add', 'create', 'implement', 'build', 'enable', 'allow',
  818. 'new feature', 'support for', 'ability to', 'want to',
  819. 'should be able', 'need to add', 'swap', 'edit', 'modify'
  820. ];
  821. const bugKeywords = [
  822. 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
  823. 'not working', 'fails', 'undefined', 'null'
  824. ];
  825. const explorationKeywords = [
  826. 'how does', 'where is', 'what is', 'find', 'show me',
  827. 'explain', 'understand', 'explore'
  828. ];
  829. const lowerTask = task.toLowerCase();
  830. // If it's clearly a bug or exploration, not a feature
  831. if (bugKeywords.some(k => lowerTask.includes(k))) return false;
  832. if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
  833. // If it matches feature keywords, it's likely a feature request
  834. return featureKeywords.some(k => lowerTask.includes(k));
  835. }
  836. /**
  837. * Handle codegraph_callers
  838. */
  839. private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
  840. const symbol = this.validateString(args.symbol, 'symbol');
  841. if (typeof symbol !== 'string') return symbol;
  842. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  843. const limit = clamp((args.limit as number) || 20, 1, 100);
  844. const allMatches = this.findAllSymbols(cg, symbol);
  845. if (allMatches.nodes.length === 0) {
  846. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  847. }
  848. // Aggregate callers across all matching symbols
  849. const seen = new Set<string>();
  850. const allCallers: Node[] = [];
  851. for (const node of allMatches.nodes) {
  852. for (const c of cg.getCallers(node.id)) {
  853. if (!seen.has(c.node.id)) {
  854. seen.add(c.node.id);
  855. allCallers.push(c.node);
  856. }
  857. }
  858. }
  859. if (allCallers.length === 0) {
  860. return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
  861. }
  862. const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
  863. return this.textResult(this.truncateOutput(formatted));
  864. }
  865. /**
  866. * Handle codegraph_callees
  867. */
  868. private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
  869. const symbol = this.validateString(args.symbol, 'symbol');
  870. if (typeof symbol !== 'string') return symbol;
  871. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  872. const limit = clamp((args.limit as number) || 20, 1, 100);
  873. const allMatches = this.findAllSymbols(cg, symbol);
  874. if (allMatches.nodes.length === 0) {
  875. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  876. }
  877. // Aggregate callees across all matching symbols
  878. const seen = new Set<string>();
  879. const allCallees: Node[] = [];
  880. for (const node of allMatches.nodes) {
  881. for (const c of cg.getCallees(node.id)) {
  882. if (!seen.has(c.node.id)) {
  883. seen.add(c.node.id);
  884. allCallees.push(c.node);
  885. }
  886. }
  887. }
  888. if (allCallees.length === 0) {
  889. return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
  890. }
  891. const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
  892. return this.textResult(this.truncateOutput(formatted));
  893. }
  894. /**
  895. * Handle codegraph_impact
  896. */
  897. private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
  898. const symbol = this.validateString(args.symbol, 'symbol');
  899. if (typeof symbol !== 'string') return symbol;
  900. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  901. const depth = clamp((args.depth as number) || 2, 1, 10);
  902. const allMatches = this.findAllSymbols(cg, symbol);
  903. if (allMatches.nodes.length === 0) {
  904. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  905. }
  906. // Aggregate impact across all matching symbols
  907. const mergedNodes = new Map<string, Node>();
  908. const mergedEdges: Edge[] = [];
  909. const seenEdges = new Set<string>();
  910. for (const node of allMatches.nodes) {
  911. const impact = cg.getImpactRadius(node.id, depth);
  912. for (const [id, n] of impact.nodes) {
  913. mergedNodes.set(id, n);
  914. }
  915. for (const e of impact.edges) {
  916. const key = `${e.source}->${e.target}:${e.kind}`;
  917. if (!seenEdges.has(key)) {
  918. seenEdges.add(key);
  919. mergedEdges.push(e);
  920. }
  921. }
  922. }
  923. const mergedImpact = {
  924. nodes: mergedNodes,
  925. edges: mergedEdges,
  926. roots: allMatches.nodes.map(n => n.id),
  927. };
  928. const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
  929. return this.textResult(this.truncateOutput(formatted));
  930. }
  931. /**
  932. * Handle codegraph_trace — shortest CALL PATH between two symbols.
  933. *
  934. * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
  935. * each hop annotated with file:line and the call-site line. This is the
  936. * capability grep/Read structurally cannot provide. When no static path
  937. * exists, the chain has almost certainly broken at dynamic dispatch
  938. * (callbacks, descriptors, metaclasses) — we say so and surface the start
  939. * symbol's outgoing calls so the agent bridges the one missing hop with
  940. * codegraph_node rather than blindly reading.
  941. */
  942. private async handleTrace(args: Record<string, unknown>): Promise<ToolResult> {
  943. const from = this.validateString(args.from, 'from');
  944. if (typeof from !== 'string') return from;
  945. const to = this.validateString(args.to, 'to');
  946. if (typeof to !== 'string') return to;
  947. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  948. const fromMatches = this.findAllSymbols(cg, from);
  949. if (fromMatches.nodes.length === 0) return this.textResult(`Symbol "${from}" not found in the codebase`);
  950. const toMatches = this.findAllSymbols(cg, to);
  951. if (toMatches.nodes.length === 0) return this.textResult(`Symbol "${to}" not found in the codebase`);
  952. // Trace along call edges only — a true call path. Names can map to several
  953. // nodes, so try a few from×to candidate pairs until a usable path turns up.
  954. //
  955. // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
  956. // is almost always a spurious wander through unrelated code (django's
  957. // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
  958. // the real execution flow — and a confident-but-wrong 15-hop trace is worse
  959. // than none. Over-cap paths are rejected and reported as "no direct path"
  960. // (which, on real code, means the flow breaks at dynamic dispatch).
  961. const edgeKinds: Edge['kind'][] = ['calls'];
  962. const MAX_HOPS = 7;
  963. const fromTry = fromMatches.nodes.slice(0, 3);
  964. const toTry = toMatches.nodes.slice(0, 3);
  965. let path: Array<{ node: Node; edge: Edge | null }> | null = null;
  966. let overCap: Array<{ node: Node; edge: Edge | null }> | null = null;
  967. for (const f of fromTry) {
  968. for (const t of toTry) {
  969. const p = cg.findPath(f.id, t.id, edgeKinds);
  970. if (!p || p.length <= 1) continue;
  971. if (p.length <= MAX_HOPS) { path = p; break; }
  972. if (!overCap || p.length < overCap.length) overCap = p;
  973. }
  974. if (path) break;
  975. }
  976. if (!path) {
  977. // No static path — almost always a dynamic-dispatch break. Surface the
  978. // start symbol's outgoing calls so the agent can bridge the gap.
  979. const start = fromTry[0]!;
  980. const callees = cg.getCallees(start.id).slice(0, 10)
  981. .map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
  982. const lines = [
  983. `No direct call path from "${from}" to "${to}".`,
  984. '',
  985. (overCap
  986. ? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
  987. : '') +
  988. 'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
  989. 'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
  990. `Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
  991. '(includeCode=true) — its body usually shows the dynamic call to follow next.',
  992. ];
  993. if (callees.length > 0) {
  994. lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
  995. }
  996. return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
  997. }
  998. const lines: string[] = [
  999. `## Trace: ${from} → ${to}`,
  1000. '',
  1001. `Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
  1002. '',
  1003. `${path.length} hops:`,
  1004. '',
  1005. ];
  1006. // Inline what each hop needs so the agent doesn't Read/Grep to get it: the
  1007. // call-site source line, the registration site for dynamic-dispatch hops, AND
  1008. // the hop's own body (capped per hop so the trace stays path-scoped). Earlier
  1009. // versions inlined only the call-site line, which left agents calling explore
  1010. // or Read for the bodies — the exact follow-up the ablation experiment measured.
  1011. const fileCache = new Map<string, string[]>();
  1012. for (let i = 0; i < path.length; i++) {
  1013. const step = path[i]!;
  1014. if (step.edge) {
  1015. const synth = this.synthEdgeNote(step.edge);
  1016. if (synth) {
  1017. lines.push(` ↓ ${synth.label}`);
  1018. if (synth.registeredAt) {
  1019. const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
  1020. lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
  1021. }
  1022. } else {
  1023. // The call happens in the PREVIOUS hop's file at edge.line.
  1024. const prev = path[i - 1];
  1025. const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
  1026. const callSrc = this.sourceLineAt(cg, ref, fileCache);
  1027. lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
  1028. }
  1029. }
  1030. lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
  1031. const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
  1032. if (body) lines.push(body);
  1033. }
  1034. // The "last mile": what the destination does next. Agents otherwise explore/Read
  1035. // for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
  1036. // so inlining the destination's callees is what actually stops the investigation —
  1037. // sufficiency, not a "don't explore" instruction.
  1038. const dest = path[path.length - 1]!.node;
  1039. const destCallees = cg.getCallees(dest.id)
  1040. .filter(c => !path.some(p => p.node.id === c.node.id))
  1041. .slice(0, 6);
  1042. if (destCallees.length > 0) {
  1043. lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
  1044. for (const c of destCallees) {
  1045. lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
  1046. const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
  1047. if (body) lines.push(body);
  1048. }
  1049. }
  1050. lines.push('', '> Full path + every hop body + the destination\'s calls are inlined above — the complete flow. Answer from it; a Read is only needed to chase a specific local variable\'s data-flow.');
  1051. return this.textResult(this.truncateOutput(lines.join('\n')));
  1052. }
  1053. /**
  1054. * Describe a synthesized (dynamic-dispatch) edge for human output: how the
  1055. * callback was wired up — the bridge static parsing can't see. Returns null
  1056. * for ordinary static edges. Used by trace + the node trail so a synthesized
  1057. * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
  1058. */
  1059. private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
  1060. if (!edge || edge.provenance !== 'heuristic') return null;
  1061. const m = edge.metadata as Record<string, unknown> | undefined;
  1062. const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
  1063. const at = registeredAt ? ` @${registeredAt}` : '';
  1064. if (m?.synthesizedBy === 'callback') {
  1065. const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
  1066. const field = m.field ? ` on .${String(m.field)}` : '';
  1067. return {
  1068. label: `callback — registered via ${via}${field} (dynamic dispatch)`,
  1069. compact: `dynamic: callback via ${via}${at}`,
  1070. registeredAt,
  1071. };
  1072. }
  1073. if (m?.synthesizedBy === 'event-emitter') {
  1074. const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
  1075. return {
  1076. label: `event ${ev} — emit → handler (dynamic dispatch)`,
  1077. compact: `dynamic: event ${ev}${at}`,
  1078. registeredAt,
  1079. };
  1080. }
  1081. if (m?.synthesizedBy === 'react-render') {
  1082. return {
  1083. label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
  1084. compact: `dynamic: React re-render via setState${at}`,
  1085. registeredAt,
  1086. };
  1087. }
  1088. if (m?.synthesizedBy === 'jsx-render') {
  1089. const child = m.via ? `<${String(m.via)}>` : 'a child component';
  1090. return {
  1091. label: `renders ${child} (JSX child — dynamic dispatch)`,
  1092. compact: `dynamic: renders ${child}`,
  1093. registeredAt,
  1094. };
  1095. }
  1096. if (m?.synthesizedBy === 'vue-handler') {
  1097. const ev = m.event ? `@${String(m.event)}` : 'a template event';
  1098. return {
  1099. label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
  1100. compact: `dynamic: Vue ${ev} handler`,
  1101. registeredAt,
  1102. };
  1103. }
  1104. if (m?.synthesizedBy === 'interface-impl') {
  1105. return {
  1106. label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
  1107. compact: `dynamic: interface → impl${at}`,
  1108. registeredAt,
  1109. };
  1110. }
  1111. return null;
  1112. }
  1113. /**
  1114. * Read one trimmed source line at "relpath:line" (relative to the project
  1115. * root). `cache` holds split file contents so a multi-hop trace reads each
  1116. * file at most once. Returns null if the file/line can't be resolved.
  1117. */
  1118. private sourceLineAt(cg: CodeGraph, ref: string | undefined, cache: Map<string, string[]>): string | null {
  1119. if (!ref) return null;
  1120. const i = ref.lastIndexOf(':');
  1121. if (i < 0) return null;
  1122. const filePath = ref.slice(0, i);
  1123. const line = parseInt(ref.slice(i + 1), 10);
  1124. if (!Number.isFinite(line) || line < 1) return null;
  1125. let fileLines = cache.get(filePath);
  1126. if (!fileLines) {
  1127. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  1128. if (!abs || !existsSync(abs)) return null;
  1129. try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
  1130. cache.set(filePath, fileLines);
  1131. }
  1132. const raw = fileLines[line - 1];
  1133. if (raw == null) return null;
  1134. const t = raw.trim();
  1135. return t.length > 160 ? t.slice(0, 157) + '…' : t;
  1136. }
  1137. /**
  1138. * Read a hop's body — filePath lines [startLine..endLine] — for inlining into
  1139. * a trace, capped (lines + chars) so the whole path stays path-scoped even on
  1140. * a 7-hop chain. Dedents to the body's own indentation and marks truncation.
  1141. * Shares `cache` with sourceLineAt so each file is read at most once per trace.
  1142. */
  1143. private sourceRangeAt(
  1144. cg: CodeGraph,
  1145. filePath: string,
  1146. startLine: number,
  1147. endLine: number,
  1148. cache: Map<string, string[]>,
  1149. maxLines = 28,
  1150. maxChars = 1200
  1151. ): string | null {
  1152. if (!Number.isFinite(startLine) || startLine < 1) return null;
  1153. let fileLines = cache.get(filePath);
  1154. if (!fileLines) {
  1155. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  1156. if (!abs || !existsSync(abs)) return null;
  1157. try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
  1158. cache.set(filePath, fileLines);
  1159. }
  1160. const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
  1161. let slice = fileLines.slice(startLine - 1, end);
  1162. if (slice.length === 0) return null;
  1163. let omitted = 0;
  1164. if (slice.length > maxLines) { omitted = slice.length - maxLines; slice = slice.slice(0, maxLines); }
  1165. const nonBlank = slice.filter(l => l.trim().length > 0);
  1166. const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
  1167. let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
  1168. if (text.length > maxChars) {
  1169. text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
  1170. omitted = Math.max(omitted, 1);
  1171. }
  1172. if (omitted > 0) text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
  1173. return text;
  1174. }
  1175. /**
  1176. * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
  1177. * symbol names that usually spans the flow it's investigating (e.g.
  1178. * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
  1179. * Surface the longest call chain AMONG those named symbols — scoped to what the
  1180. * agent explicitly named, so (unlike a fuzzy relevance set) there's no
  1181. * wrong-feature wandering. Rides synthesized edges, so controller→service-
  1182. * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
  1183. *
  1184. * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
  1185. * CO-NAMING: the agent names the class too, so we keep only `list` candidates
  1186. * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
  1187. * dropping unrelated `OmsOrderService::list`.
  1188. */
  1189. private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): string {
  1190. try {
  1191. const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
  1192. // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
  1193. // names (Class.method / Class::method) — the agent's most precise input,
  1194. // resolved exactly by findAllSymbols. (The old strip mangled Class.method
  1195. // into Class, throwing the method away.)
  1196. const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
  1197. const tokens = [...new Set(
  1198. query.split(/[\s,()[\]]+/)
  1199. .map((t) => t.replace(FILE_EXT, '').trim())
  1200. .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
  1201. )].slice(0, 16);
  1202. if (tokens.length < 2) return '';
  1203. // Pool of name SEGMENTS (Class + method from every token) used to
  1204. // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
  1205. // CONTAINER class is itself named in the query.
  1206. const segPool = new Set<string>();
  1207. for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
  1208. const named = new Map<string, Node>();
  1209. for (const t of tokens) {
  1210. const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
  1211. // A qualified or otherwise-specific name (<=3 hits) keeps all; an
  1212. // ambiguous simple name keeps only candidates whose container is named.
  1213. const pick = cands.length <= 3
  1214. ? cands
  1215. : cands.filter((n) => {
  1216. const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
  1217. const container = segs.length >= 2 ? segs[segs.length - 2] : '';
  1218. return !!container && segPool.has(container);
  1219. });
  1220. for (const n of pick.slice(0, 6)) named.set(n.id, n);
  1221. if (named.size > 40) break;
  1222. }
  1223. if (named.size < 2) return '';
  1224. const MAX_HOPS = 7;
  1225. let best: Array<{ node: Node; edge: Edge | null }> | null = null;
  1226. // BFS the full call graph (incl. synth edges) from each named seed, but
  1227. // only ACCEPT a sink that is also named — both ends anchored to symbols the
  1228. // agent named, so the chain stays on-topic while bridging intermediates
  1229. // (e.g. the exact interface overload) that the token resolution missed.
  1230. for (const seed of [...named.values()].slice(0, 8)) {
  1231. const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
  1232. parent.set(seed.id, { prev: null, edge: null, node: seed });
  1233. const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
  1234. let deep: string | null = null, deepDepth = 0;
  1235. const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
  1236. for (let h = 0; h < q.length && parent.size < 1500; h++) {
  1237. const { id, depth, streak } = q[h]!;
  1238. if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
  1239. if (depth >= MAX_HOPS - 1) continue;
  1240. for (const c of cg.getCallees(id)) {
  1241. if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
  1242. const newStreak = named.has(c.node.id) ? 0 : streak + 1;
  1243. if (newStreak > MAX_BRIDGE) continue;
  1244. parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
  1245. q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
  1246. }
  1247. }
  1248. if (!deep) continue;
  1249. const chain: Array<{ node: Node; edge: Edge | null }> = [];
  1250. let cur: string | null = deep;
  1251. while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
  1252. chain.reverse();
  1253. if (!best || chain.length > best.length) best = chain;
  1254. }
  1255. if (!best || best.length < 3) return '';
  1256. const out = ['## Flow (call path among the symbols you queried)', ''];
  1257. for (let i = 0; i < best.length; i++) {
  1258. const step = best[i]!;
  1259. if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`); }
  1260. out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
  1261. }
  1262. out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
  1263. return out.join('\n');
  1264. } catch {
  1265. return '';
  1266. }
  1267. }
  1268. /**
  1269. * Handle codegraph_explore — deep exploration in a single call
  1270. *
  1271. * Strategy: find relevant symbols via graph traversal, group by file,
  1272. * then read contiguous file sections covering all symbols per file.
  1273. * This replaces multiple codegraph_node + Read calls.
  1274. *
  1275. * Output size is adaptive to project file count via
  1276. * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
  1277. * tax on small projects while earning its keep on large ones.
  1278. */
  1279. private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
  1280. const query = this.validateString(args.query, 'query');
  1281. if (typeof query !== 'string') return query;
  1282. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1283. const projectRoot = cg.getProjectRoot();
  1284. // Resolve adaptive output budget from project size. Falls back to the
  1285. // largest-tier defaults if stats aren't available, which preserves
  1286. // pre-#185 behavior for callers that hit the rare stats failure.
  1287. let budget: ExploreOutputBudget;
  1288. try {
  1289. budget = getExploreOutputBudget(cg.getStats().fileCount);
  1290. } catch {
  1291. budget = getExploreOutputBudget(Infinity);
  1292. }
  1293. const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
  1294. // Step 1: Find relevant context with generous parameters.
  1295. // Use a large maxNodes budget — explore has its own 35k char output limit
  1296. // that prevents context bloat, so more nodes just means better coverage
  1297. // across entry points (especially for large files like Svelte components).
  1298. const subgraph = await cg.findRelevantContext(query, {
  1299. searchLimit: 8,
  1300. traversalDepth: 3,
  1301. maxNodes: 200,
  1302. minScore: 0.2,
  1303. });
  1304. if (subgraph.nodes.size === 0) {
  1305. return this.textResult(`No relevant code found for "${query}"`);
  1306. }
  1307. // Graph-aware glue: findRelevantContext builds the subgraph from name/text
  1308. // search, so a method that BRIDGES named symbols — e.g. App.tsx's
  1309. // triggerRender, which calls the named triggerUpdate — is never a search hit
  1310. // and gets missed, forcing the agent to Read the file to trace it. Pull in
  1311. // the callers/callees of the entry (root) nodes, but ONLY those that live in
  1312. // files the subgraph already surfaces (where the agent reads to fill gaps),
  1313. // so we add wiring without dragging in unrelated files. These get an
  1314. // importance boost below so they survive the per-file cluster budget.
  1315. const glueNodeIds = new Set<string>();
  1316. const subgraphFiles = new Set<string>();
  1317. for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
  1318. const GLUE_NODE_CAP = 60;
  1319. for (const rootId of subgraph.roots) {
  1320. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1321. let neighbors: Node[] = [];
  1322. try {
  1323. neighbors = [
  1324. ...cg.getCallers(rootId).map(c => c.node),
  1325. ...cg.getCallees(rootId).map(c => c.node),
  1326. ];
  1327. } catch {
  1328. continue;
  1329. }
  1330. for (const nb of neighbors) {
  1331. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1332. if (subgraph.nodes.has(nb.id)) continue;
  1333. if (!subgraphFiles.has(nb.filePath)) continue;
  1334. subgraph.nodes.set(nb.id, nb);
  1335. glueNodeIds.add(nb.id);
  1336. }
  1337. }
  1338. // Step 2: Group nodes by file, score by relevance
  1339. const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
  1340. const entryNodeIds = new Set(subgraph.roots);
  1341. // Build a set of nodes directly connected to entry points (depth 1)
  1342. const connectedToEntry = new Set<string>();
  1343. for (const edge of subgraph.edges) {
  1344. if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
  1345. if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
  1346. }
  1347. for (const node of subgraph.nodes.values()) {
  1348. // Skip import/export nodes — they add noise without information
  1349. if (node.kind === 'import' || node.kind === 'export') continue;
  1350. const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
  1351. group.nodes.push(node);
  1352. // Score: entry point nodes worth 10, directly connected worth 3, others worth 1
  1353. if (entryNodeIds.has(node.id)) {
  1354. group.score += 10;
  1355. } else if (connectedToEntry.has(node.id)) {
  1356. group.score += 3;
  1357. } else {
  1358. group.score += 1;
  1359. }
  1360. fileGroups.set(node.filePath, group);
  1361. }
  1362. // Only include files that have entry points or nodes directly connected to entry points
  1363. const relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
  1364. // Extract query terms for relevance checking
  1365. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  1366. // Sort files: highest relevance first, deprioritize low-value files
  1367. const sortedFiles = relevantFiles.sort((a, b) => {
  1368. const aPath = a[0].toLowerCase();
  1369. const bPath = b[0].toLowerCase();
  1370. // Check if any node name or file path relates to query terms
  1371. const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
  1372. const fp = filePath.toLowerCase();
  1373. if (queryTerms.some(t => fp.includes(t))) return true;
  1374. return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
  1375. };
  1376. const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
  1377. const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
  1378. if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
  1379. // Deprioritize test files, icon files, and i18n files
  1380. const isLowValue = (p: string) =>
  1381. /\/(tests?|__tests?__|spec)\//i.test(p) ||
  1382. /\bicons?\b/i.test(p) ||
  1383. /\bi18n\b/i.test(p);
  1384. const aLow = isLowValue(aPath);
  1385. const bLow = isLowValue(bPath);
  1386. if (aLow !== bLow) return aLow ? 1 : -1;
  1387. if (a[1].score !== b[1].score) return b[1].score - a[1].score;
  1388. return b[1].nodes.length - a[1].nodes.length;
  1389. });
  1390. // Step 3: Build relationship map
  1391. const lines: string[] = [
  1392. `## Exploration: ${query}`,
  1393. '',
  1394. `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
  1395. '',
  1396. ];
  1397. // Relationship map — show how symbols connect
  1398. const significantEdges = subgraph.edges.filter(e =>
  1399. e.kind !== 'contains' // skip contains — it's implied by file grouping
  1400. );
  1401. if (budget.includeRelationships && significantEdges.length > 0) {
  1402. lines.push('### Relationships');
  1403. lines.push('');
  1404. // Group edges by kind for readability
  1405. const byKind = new Map<string, Array<{ source: string; target: string }>>();
  1406. for (const edge of significantEdges) {
  1407. const sourceNode = subgraph.nodes.get(edge.source);
  1408. const targetNode = subgraph.nodes.get(edge.target);
  1409. if (!sourceNode || !targetNode) continue;
  1410. const group = byKind.get(edge.kind) || [];
  1411. group.push({ source: sourceNode.name, target: targetNode.name });
  1412. byKind.set(edge.kind, group);
  1413. }
  1414. for (const [kind, edges] of byKind) {
  1415. const cap = budget.maxEdgesPerRelationshipKind;
  1416. const shown = edges.slice(0, cap);
  1417. lines.push(`**${kind}:**`);
  1418. for (const e of shown) {
  1419. lines.push(`- ${e.source} → ${e.target}`);
  1420. }
  1421. if (edges.length > cap) {
  1422. lines.push(`- ... and ${edges.length - cap} more`);
  1423. }
  1424. lines.push('');
  1425. }
  1426. }
  1427. // Step 4: Read contiguous file sections
  1428. lines.push('### Source Code');
  1429. lines.push('');
  1430. 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.');
  1431. lines.push('');
  1432. let totalChars = lines.join('\n').length;
  1433. let filesIncluded = 0;
  1434. let anyFileTrimmed = false;
  1435. for (const [filePath, group] of sortedFiles) {
  1436. if (filesIncluded >= maxFiles) break;
  1437. if (totalChars > budget.maxOutputChars * 0.9) break;
  1438. const absPath = validatePathWithinRoot(projectRoot, filePath);
  1439. if (!absPath || !existsSync(absPath)) continue;
  1440. let fileContent: string;
  1441. try {
  1442. fileContent = readFileSync(absPath, 'utf-8');
  1443. } catch {
  1444. continue;
  1445. }
  1446. const fileLines = fileContent.split('\n');
  1447. const lang = group.nodes[0]?.language || '';
  1448. // Whole-small-file rule: if a relevant file is small enough to afford,
  1449. // return it ENTIRELY instead of clustering. Clustering exists to tame
  1450. // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
  1451. // lossy subset of a file the agent will just Read in full anyway — costing
  1452. // a round-trip and a re-read every later turn. Reserve clustering for files
  1453. // too big to ship whole. Still bounded by the total maxOutputChars check.
  1454. const WHOLE_FILE_MAX_LINES = 220;
  1455. const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
  1456. if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
  1457. const body = fileContent.replace(/\n+$/, '');
  1458. let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
  1459. const uniqSymbols = [...new Set(
  1460. group.nodes
  1461. .filter(n => n.kind !== 'import' && n.kind !== 'export')
  1462. .map(n => `${n.name}(${n.kind})`)
  1463. )];
  1464. const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
  1465. const omitted = uniqSymbols.length - headerNames.length;
  1466. const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
  1467. if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
  1468. const remaining = budget.maxOutputChars - totalChars - 200;
  1469. if (remaining < 500) break;
  1470. wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
  1471. anyFileTrimmed = true;
  1472. }
  1473. lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
  1474. totalChars += wholeSection.length + 200;
  1475. filesIncluded++;
  1476. continue;
  1477. }
  1478. // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
  1479. // Sort by start line, then merge overlapping/adjacent ranges (within the
  1480. // adaptive gap threshold). Include both node ranges AND edge source
  1481. // locations so template sections with component usages/calls are
  1482. // covered (not just script block symbols).
  1483. //
  1484. // Each range carries an `importance` score so we can rank clusters
  1485. // when the per-file budget forces us to drop some: entry-point nodes
  1486. // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
  1487. // bare edge-source lines 2 (less than a connected node but more than
  1488. // a peripheral one — they hint at a reference but aren't a definition).
  1489. // Container kinds whose body can span most/all of a file. When such a
  1490. // node covers most of the file we drop it from the ranges: keeping it
  1491. // would merge every method inside it into one giant cluster spanning
  1492. // the whole file, which then tail-trims down to just the container's
  1493. // opening lines (its header/declarations) and buries the methods the
  1494. // query actually asked about (#185 follow-up — Session.swift in
  1495. // Alamofire is the canonical case: the `Session` class spans ~1,400
  1496. // lines). We want the granular symbols inside, not the envelope.
  1497. const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
  1498. const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
  1499. .filter(n => n.startLine > 0 && n.endLine > 0)
  1500. // Drop whole-file envelope nodes (containers covering >50% of the file).
  1501. .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
  1502. .map(n => {
  1503. let importance = 1;
  1504. if (entryNodeIds.has(n.id)) importance = 10;
  1505. else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
  1506. else if (connectedToEntry.has(n.id)) importance = 3;
  1507. return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
  1508. });
  1509. // Add edge source locations in this file — captures template references
  1510. // (component usages, event handlers) that aren't nodes themselves.
  1511. // Query edges directly from the DB (not just the subgraph) because BFS
  1512. // traversal may have pruned template reference targets due to node budget.
  1513. const edgeLines = new Set<string>(); // dedup by "line:name"
  1514. for (const node of group.nodes) {
  1515. const outgoing = cg.getOutgoingEdges(node.id);
  1516. for (const edge of outgoing) {
  1517. if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
  1518. const key = `${edge.line}:${edge.target}`;
  1519. if (edgeLines.has(key)) continue;
  1520. edgeLines.add(key);
  1521. // Look up target name from subgraph first, fall back to edge kind
  1522. const targetNode = subgraph.nodes.get(edge.target);
  1523. const targetName = targetNode?.name ?? edge.kind;
  1524. ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
  1525. }
  1526. }
  1527. ranges.sort((a, b) => a.start - b.start);
  1528. if (ranges.length === 0) continue;
  1529. const gapThreshold = budget.gapThreshold;
  1530. const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
  1531. let current = {
  1532. start: ranges[0]!.start,
  1533. end: ranges[0]!.end,
  1534. symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
  1535. score: ranges[0]!.importance,
  1536. maxImportance: ranges[0]!.importance,
  1537. };
  1538. for (let i = 1; i < ranges.length; i++) {
  1539. const r = ranges[i]!;
  1540. if (r.start <= current.end + gapThreshold) {
  1541. current.end = Math.max(current.end, r.end);
  1542. current.symbols.push(`${r.name}(${r.kind})`);
  1543. current.score += r.importance;
  1544. current.maxImportance = Math.max(current.maxImportance, r.importance);
  1545. } else {
  1546. clusters.push(current);
  1547. current = {
  1548. start: r.start,
  1549. end: r.end,
  1550. symbols: [`${r.name}(${r.kind})`],
  1551. score: r.importance,
  1552. maxImportance: r.importance,
  1553. };
  1554. }
  1555. }
  1556. clusters.push(current);
  1557. // Build file section output from clusters, capped by per-file budget.
  1558. // The pathological case (#185): a file like Session.swift where every
  1559. // method is adjacent collapses into one cluster spanning the whole
  1560. // file, and dumping that into the agent's context is most of the
  1561. // token cost on small projects. We pick clusters in priority order
  1562. // until the per-file char cap is hit. Truly enormous single clusters
  1563. // get tail-trimmed with a marker.
  1564. const contextPadding = 3;
  1565. const withLineNumbers = exploreLineNumbersEnabled();
  1566. const buildSection = (c: { start: number; end: number }): string => {
  1567. const startIdx = Math.max(0, c.start - 1 - contextPadding);
  1568. const endIdx = Math.min(fileLines.length, c.end + contextPadding);
  1569. const slice = fileLines.slice(startIdx, endIdx).join('\n');
  1570. // startIdx is 0-based, so the slice's first line is line startIdx + 1.
  1571. return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
  1572. };
  1573. // Language-neutral separator (no `//` — not a comment in Python, Ruby,
  1574. // etc.). With line numbers on, the line-number jump also signals the gap.
  1575. const GAP_MARKER = '\n\n... (gap) ...\n\n';
  1576. // Rank clusters for inclusion under the per-file cap. Entry-point
  1577. // clusters come first: a cluster containing a query entry point
  1578. // (importance 10) must outrank a dense block of mere declarations,
  1579. // otherwise on a large file like Session.swift the top-of-file class
  1580. // header + property list (many adjacent low-importance nodes, high
  1581. // density) wins the budget and buries the actual methods the query
  1582. // asked about (perform/didCreateURLRequest/task live deep in the
  1583. // file). Within the same importance tier, prefer density (score per
  1584. // line) so we still favor focused clusters over sprawling ones, then
  1585. // smaller span as a cheap-to-include tiebreak.
  1586. const rankedClusters = clusters
  1587. .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
  1588. .sort((a, b) => {
  1589. if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
  1590. const densityA = a.c.score / a.span;
  1591. const densityB = b.c.score / b.span;
  1592. if (densityB !== densityA) return densityB - densityA;
  1593. if (b.c.score !== a.c.score) return b.c.score - a.c.score;
  1594. return a.span - b.span;
  1595. });
  1596. const chosenIndices = new Set<number>();
  1597. let projectedChars = 0;
  1598. for (const rc of rankedClusters) {
  1599. const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
  1600. // Always take the top-ranked cluster, even if oversize, so we don't
  1601. // return an empty file section (agent would then re-Read the file,
  1602. // negating the savings).
  1603. if (chosenIndices.size === 0) {
  1604. chosenIndices.add(rc.idx);
  1605. projectedChars += sectionLen;
  1606. continue;
  1607. }
  1608. if (projectedChars + sectionLen > budget.maxCharsPerFile) continue;
  1609. chosenIndices.add(rc.idx);
  1610. projectedChars += sectionLen;
  1611. }
  1612. // Emit chosen clusters in source order so the file reads top-to-bottom.
  1613. let fileSection = '';
  1614. const allSymbols: string[] = [];
  1615. let fileTrimmed = false;
  1616. for (let i = 0; i < clusters.length; i++) {
  1617. if (!chosenIndices.has(i)) continue;
  1618. const cluster = clusters[i]!;
  1619. const section = buildSection(cluster);
  1620. if (fileSection.length > 0) fileSection += GAP_MARKER;
  1621. fileSection += section;
  1622. allSymbols.push(...cluster.symbols);
  1623. }
  1624. // If a single chosen cluster is still oversize (long monolithic
  1625. // function), tail-trim it. Better one trimmed view than nothing.
  1626. if (fileSection.length > budget.maxCharsPerFile) {
  1627. fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
  1628. fileTrimmed = true;
  1629. }
  1630. if (chosenIndices.size < clusters.length || fileTrimmed) {
  1631. anyFileTrimmed = true;
  1632. }
  1633. // Dedupe + cap the symbols list shown in the per-file header. Some
  1634. // files (Session.swift in Alamofire) produced 3.4KB symbol lists
  1635. // from cluster scoring + edge-source lines, dwarfing the per-file
  1636. // body cap. Show top names by frequency, with a "+N more" tail.
  1637. const symbolCounts = new Map<string, number>();
  1638. for (const s of allSymbols) {
  1639. symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
  1640. }
  1641. const sortedSymbols = [...symbolCounts.entries()]
  1642. .sort((a, b) => b[1] - a[1])
  1643. .map(([name]) => name);
  1644. const headerCap = budget.maxSymbolsInFileHeader;
  1645. const headerSymbols = sortedSymbols.slice(0, headerCap);
  1646. const omittedCount = sortedSymbols.length - headerSymbols.length;
  1647. const headerSuffix = omittedCount > 0
  1648. ? `${headerSymbols.join(', ')}, +${omittedCount} more`
  1649. : headerSymbols.join(', ');
  1650. const fileHeader = `#### ${filePath} — ${headerSuffix}`;
  1651. // Respect the total output cap on a file-by-file basis.
  1652. if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
  1653. const remaining = budget.maxOutputChars - totalChars - 200;
  1654. if (remaining < 500) break;
  1655. const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
  1656. lines.push(fileHeader);
  1657. lines.push('');
  1658. lines.push('```' + lang);
  1659. lines.push(trimmed);
  1660. lines.push('```');
  1661. lines.push('');
  1662. totalChars += trimmed.length + 200;
  1663. filesIncluded++;
  1664. anyFileTrimmed = true;
  1665. break;
  1666. }
  1667. lines.push(fileHeader);
  1668. lines.push('');
  1669. lines.push('```' + lang);
  1670. lines.push(fileSection);
  1671. lines.push('```');
  1672. lines.push('');
  1673. totalChars += fileSection.length + 200;
  1674. filesIncluded++;
  1675. }
  1676. // Add remaining files as references (from both relevant and peripheral files).
  1677. // Small projects (per budget) skip this — the relevant story already fits
  1678. // in the source section, and a trailing pointer list is pure overhead.
  1679. if (budget.includeAdditionalFiles) {
  1680. const remainingRelevant = sortedFiles.slice(filesIncluded);
  1681. const peripheralFiles = [...fileGroups.entries()]
  1682. .filter(([, group]) => group.score < 3)
  1683. .sort((a, b) => b[1].score - a[1].score);
  1684. const remainingFiles = [...remainingRelevant, ...peripheralFiles];
  1685. if (remainingFiles.length > 0) {
  1686. lines.push('### Not shown above — explore these names for their source');
  1687. lines.push('');
  1688. for (const [filePath, group] of remainingFiles.slice(0, 10)) {
  1689. const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  1690. lines.push(`- ${filePath}: ${symbols}`);
  1691. }
  1692. if (remainingFiles.length > 10) {
  1693. lines.push(`- ... and ${remainingFiles.length - 10} more files`);
  1694. }
  1695. }
  1696. }
  1697. // Add completeness signal so agents know they don't need to re-read these files.
  1698. // On small projects the budget gates this off — but if we actually had to
  1699. // trim or drop clusters, surface a brief note so the agent knows it can
  1700. // still Read for more detail.
  1701. if (budget.includeCompletenessSignal) {
  1702. lines.push('');
  1703. lines.push('---');
  1704. 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.`);
  1705. } else if (anyFileTrimmed) {
  1706. lines.push('');
  1707. 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.`);
  1708. }
  1709. // Add explore budget note based on project size
  1710. if (budget.includeBudgetNote) {
  1711. try {
  1712. const stats = cg.getStats();
  1713. const callBudget = getExploreBudget(stats.fileCount);
  1714. lines.push('');
  1715. 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}.`);
  1716. } catch {
  1717. // Stats unavailable — skip budget note
  1718. }
  1719. }
  1720. // Hard-cap to the adaptive budget. The per-file loop bounds the source
  1721. // sections, but the relationship map, additional-files list, and
  1722. // completeness/budget notes can still push the assembled output past
  1723. // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
  1724. // payload persists in the agent's context and is re-read as cache-input
  1725. // on every subsequent turn, so the overrun is paid many times over.
  1726. const output = this.buildFlowFromNamedSymbols(cg, query) + lines.join('\n');
  1727. if (output.length > budget.maxOutputChars) {
  1728. const cut = output.slice(0, budget.maxOutputChars);
  1729. const lastNewline = cut.lastIndexOf('\n');
  1730. const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
  1731. 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.)');
  1732. }
  1733. return this.textResult(output);
  1734. }
  1735. /**
  1736. * Handle codegraph_node
  1737. */
  1738. private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
  1739. const symbol = this.validateString(args.symbol, 'symbol');
  1740. if (typeof symbol !== 'string') return symbol;
  1741. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1742. // Default to false to minimize context usage
  1743. const includeCode = args.includeCode === true;
  1744. const match = this.findSymbol(cg, symbol);
  1745. if (!match) {
  1746. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  1747. }
  1748. let code: string | null = null;
  1749. let outline: string | null = null;
  1750. if (includeCode) {
  1751. // For container symbols (class/interface/struct/…), the full body is the
  1752. // sum of every method body — a wall of source (e.g. a 10k-char class)
  1753. // that bloats context and is rarely needed in full. Return a structural
  1754. // outline (members + signatures + line numbers) instead; the agent can
  1755. // Read or codegraph_node a specific method for its body. Leaf symbols
  1756. // (function/method/etc.) return their full body as before.
  1757. if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
  1758. outline = this.buildContainerOutline(cg, match.node);
  1759. }
  1760. if (!outline) {
  1761. code = await cg.getCode(match.node.id);
  1762. }
  1763. }
  1764. const trail = this.formatTrail(cg, match.node);
  1765. const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
  1766. return this.textResult(this.truncateOutput(formatted));
  1767. }
  1768. /**
  1769. * Build the "trail" for a symbol: its direct callees (what it calls) and
  1770. * callers (what calls it), each with file:line — so codegraph_node doubles as
  1771. * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
  1772. * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
  1773. * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
  1774. * dynamic dispatch the static graph couldn't resolve — that absence is itself
  1775. * a signal (read that one hop) rather than a dead end.
  1776. */
  1777. private formatTrail(cg: CodeGraph, node: Node): string {
  1778. const TRAIL_CAP = 12;
  1779. const fmt = (e: { node: Node; edge: Edge }) => {
  1780. const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
  1781. const synth = this.synthEdgeNote(e.edge);
  1782. return synth ? `${base} [${synth.compact}]` : base;
  1783. };
  1784. const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
  1785. const seen = new Set<string>([node.id]);
  1786. const out: Array<{ node: Node; edge: Edge }> = [];
  1787. for (const e of edges) {
  1788. if (seen.has(e.node.id)) continue;
  1789. seen.add(e.node.id);
  1790. out.push(e);
  1791. }
  1792. return out;
  1793. };
  1794. const callees = collect(cg.getCallees(node.id));
  1795. const callers = collect(cg.getCallers(node.id));
  1796. if (callees.length === 0 && callers.length === 0) return '';
  1797. const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
  1798. if (callees.length > 0) {
  1799. lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
  1800. }
  1801. if (callers.length > 0) {
  1802. lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
  1803. }
  1804. return lines.join('\n');
  1805. }
  1806. /**
  1807. * Handle codegraph_status
  1808. */
  1809. private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
  1810. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1811. const stats = cg.getStats();
  1812. const lines: string[] = [
  1813. '## CodeGraph Status',
  1814. '',
  1815. `**Files indexed:** ${stats.fileCount}`,
  1816. `**Total nodes:** ${stats.nodeCount}`,
  1817. `**Total edges:** ${stats.edgeCount}`,
  1818. `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
  1819. ];
  1820. // Surface the active SQLite backend (node:sqlite, Node's built-in real
  1821. // SQLite — full WAL + FTS5, no native build).
  1822. lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
  1823. // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
  1824. // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
  1825. // everywhere, so a non-wal mode means the filesystem can't (network/
  1826. // virtualized mounts, WSL2 /mnt). See issue #238.
  1827. const journalMode = cg.getJournalMode();
  1828. if (journalMode === 'wal') {
  1829. lines.push(`**Journal mode:** wal (concurrent reads safe)`);
  1830. } else {
  1831. lines.push(
  1832. `**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
  1833. `can block on a concurrent write (WAL appears unsupported on this filesystem)`
  1834. );
  1835. }
  1836. lines.push('', '### Nodes by Kind:');
  1837. for (const [kind, count] of Object.entries(stats.nodesByKind)) {
  1838. if ((count as number) > 0) {
  1839. lines.push(`- ${kind}: ${count}`);
  1840. }
  1841. }
  1842. lines.push('', '### Languages:');
  1843. for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
  1844. if ((count as number) > 0) {
  1845. lines.push(`- ${lang}: ${count}`);
  1846. }
  1847. }
  1848. return this.textResult(lines.join('\n'));
  1849. }
  1850. /**
  1851. * Handle codegraph_files - get project file structure from the index
  1852. */
  1853. private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
  1854. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1855. const pathFilter = args.path as string | undefined;
  1856. const pattern = args.pattern as string | undefined;
  1857. const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
  1858. const includeMetadata = args.includeMetadata !== false;
  1859. const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
  1860. // Get all files from the index
  1861. const allFiles = cg.getFiles();
  1862. if (allFiles.length === 0) {
  1863. return this.textResult('No files indexed. Run `codegraph index` first.');
  1864. }
  1865. // Filter by path prefix
  1866. let files = pathFilter
  1867. ? allFiles.filter(f => f.path.startsWith(pathFilter) || f.path.startsWith('./' + pathFilter))
  1868. : allFiles;
  1869. // Filter by glob pattern
  1870. if (pattern) {
  1871. const regex = this.globToRegex(pattern);
  1872. files = files.filter(f => regex.test(f.path));
  1873. }
  1874. if (files.length === 0) {
  1875. return this.textResult(`No files found matching the criteria.`);
  1876. }
  1877. // Format output
  1878. let output: string;
  1879. switch (format) {
  1880. case 'flat':
  1881. output = this.formatFilesFlat(files, includeMetadata);
  1882. break;
  1883. case 'grouped':
  1884. output = this.formatFilesGrouped(files, includeMetadata);
  1885. break;
  1886. case 'tree':
  1887. default:
  1888. output = this.formatFilesTree(files, includeMetadata, maxDepth);
  1889. break;
  1890. }
  1891. return this.textResult(this.truncateOutput(output));
  1892. }
  1893. /**
  1894. * Convert glob pattern to regex
  1895. */
  1896. private globToRegex(pattern: string): RegExp {
  1897. const escaped = pattern
  1898. .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
  1899. .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
  1900. .replace(/\*/g, '[^/]*') // * matches anything except /
  1901. .replace(/\?/g, '[^/]') // ? matches single char except /
  1902. .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
  1903. return new RegExp(escaped);
  1904. }
  1905. /**
  1906. * Format files as a flat list
  1907. */
  1908. private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1909. const lines: string[] = [`## Files (${files.length})`, ''];
  1910. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  1911. if (includeMetadata) {
  1912. lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
  1913. } else {
  1914. lines.push(`- ${file.path}`);
  1915. }
  1916. }
  1917. return lines.join('\n');
  1918. }
  1919. /**
  1920. * Format files grouped by language
  1921. */
  1922. private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1923. const byLang = new Map<string, typeof files>();
  1924. for (const file of files) {
  1925. const existing = byLang.get(file.language) || [];
  1926. existing.push(file);
  1927. byLang.set(file.language, existing);
  1928. }
  1929. const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
  1930. // Sort languages by file count (descending)
  1931. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  1932. for (const [lang, langFiles] of sortedLangs) {
  1933. lines.push(`### ${lang} (${langFiles.length})`);
  1934. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  1935. if (includeMetadata) {
  1936. lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
  1937. } else {
  1938. lines.push(`- ${file.path}`);
  1939. }
  1940. }
  1941. lines.push('');
  1942. }
  1943. return lines.join('\n');
  1944. }
  1945. /**
  1946. * Format files as a tree structure
  1947. */
  1948. private formatFilesTree(
  1949. files: { path: string; language: string; nodeCount: number }[],
  1950. includeMetadata: boolean,
  1951. maxDepth?: number
  1952. ): string {
  1953. // Build tree structure
  1954. interface TreeNode {
  1955. name: string;
  1956. children: Map<string, TreeNode>;
  1957. file?: { language: string; nodeCount: number };
  1958. }
  1959. const root: TreeNode = { name: '', children: new Map() };
  1960. for (const file of files) {
  1961. const parts = file.path.split('/');
  1962. let current = root;
  1963. for (let i = 0; i < parts.length; i++) {
  1964. const part = parts[i];
  1965. if (!part) continue;
  1966. if (!current.children.has(part)) {
  1967. current.children.set(part, { name: part, children: new Map() });
  1968. }
  1969. current = current.children.get(part)!;
  1970. // If this is the last part, it's a file
  1971. if (i === parts.length - 1) {
  1972. current.file = { language: file.language, nodeCount: file.nodeCount };
  1973. }
  1974. }
  1975. }
  1976. // Render tree
  1977. const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
  1978. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  1979. if (maxDepth !== undefined && depth > maxDepth) return;
  1980. const connector = isLast ? '└── ' : '├── ';
  1981. const childPrefix = isLast ? ' ' : '│ ';
  1982. if (node.name) {
  1983. let line = prefix + connector + node.name;
  1984. if (node.file && includeMetadata) {
  1985. line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
  1986. }
  1987. lines.push(line);
  1988. }
  1989. const children = [...node.children.values()];
  1990. // Sort: directories first, then files, both alphabetically
  1991. children.sort((a, b) => {
  1992. const aIsDir = a.children.size > 0 && !a.file;
  1993. const bIsDir = b.children.size > 0 && !b.file;
  1994. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  1995. return a.name.localeCompare(b.name);
  1996. });
  1997. for (let i = 0; i < children.length; i++) {
  1998. const child = children[i]!;
  1999. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  2000. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  2001. }
  2002. };
  2003. renderNode(root, '', true, 0);
  2004. return lines.join('\n');
  2005. }
  2006. // =========================================================================
  2007. // Symbol resolution helpers
  2008. // =========================================================================
  2009. /**
  2010. * Find a symbol by name, handling disambiguation when multiple matches exist.
  2011. * Returns the best match and a note about alternatives if any.
  2012. */
  2013. /**
  2014. * Check if a node matches a symbol query.
  2015. *
  2016. * Accepts simple names (`run`) and three flavors of qualifier:
  2017. * - dotted `Session.request` (TS/JS/Python)
  2018. * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
  2019. * - slash `configurator/stage_apply` (path-ish)
  2020. *
  2021. * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
  2022. * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
  2023. * the canonical `crate::module::symbol` form resolves.
  2024. *
  2025. * Resolution order, last part must always equal `node.name`:
  2026. * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
  2027. * where the extractor builds the qualified name from the AST stack)
  2028. * 2. File-path containment (handles file-derived modules in Rust/
  2029. * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
  2030. */
  2031. private matchesSymbol(node: Node, symbol: string): boolean {
  2032. // Simple name match
  2033. if (node.name === symbol) return true;
  2034. // File basename match (e.g., "product-card" matches "product-card.liquid")
  2035. if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
  2036. // Qualified-name lookups: split on any supported separator. `\w` keeps
  2037. // identifier chars (incl. `_`) intact; everything else is treated as
  2038. // a separator we tolerate.
  2039. if (!/[.\/]|::/.test(symbol)) return false;
  2040. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  2041. if (parts.length < 2) return false;
  2042. const lastPart = parts[parts.length - 1]!;
  2043. if (node.name !== lastPart) return false;
  2044. // Stage 1: qualified-name suffix match. The extractor joins the
  2045. // semantic hierarchy with `::`, so `Session.request` and
  2046. // `Session::request` both become `Session::request` here.
  2047. const colonSuffix = parts.join('::');
  2048. if (node.qualifiedName.includes(colonSuffix)) return true;
  2049. // Stage 2: file-path containment. Rust modules and Python packages
  2050. // are not in `qualifiedName` — they're encoded in the file path. So
  2051. // `stage_apply::run` matches a `run` in any file whose path
  2052. // contains a `stage_apply` segment (with or without an extension).
  2053. //
  2054. // Filter out Rust path prefixes that have no file-system equivalent.
  2055. const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
  2056. if (containerHints.length === 0) return false;
  2057. const segments = node.filePath.split('/').filter((s) => s.length > 0);
  2058. return containerHints.every((hint) =>
  2059. segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
  2060. );
  2061. }
  2062. private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
  2063. // Use higher limit for qualified lookups (e.g., "Session.request",
  2064. // "stage_apply::run") since the target may rank lower in FTS when
  2065. // there are many partial matches across the qualifier parts.
  2066. const isQualified = /[.\/]|::/.test(symbol);
  2067. const limit = isQualified ? 50 : 10;
  2068. let results = cg.searchNodes(symbol, { limit });
  2069. // FTS strips colons as a special char, so `stage_apply::run` searches
  2070. // for the literal `stage_applyrun` and finds nothing. Re-search by
  2071. // the bare last part and let `matchesSymbol` filter by qualifier.
  2072. if (isQualified && results.length === 0) {
  2073. const tail = lastQualifierPart(symbol);
  2074. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
  2075. }
  2076. if (results.length === 0 || !results[0]) {
  2077. return null;
  2078. }
  2079. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  2080. if (exactMatches.length === 1) {
  2081. return { node: exactMatches[0]!.node, note: '' };
  2082. }
  2083. if (exactMatches.length > 1) {
  2084. // Multiple exact matches - pick first, note the others
  2085. const picked = exactMatches[0]!.node;
  2086. const others = exactMatches.slice(1).map(r =>
  2087. `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
  2088. );
  2089. const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
  2090. return { node: picked, note };
  2091. }
  2092. // No exact match. For qualified lookups, don't silently fall back
  2093. // to a fuzzy result — the user typed a specific qualifier, and
  2094. // resolving `stage_apply::nonexistent_fn` to the unrelated
  2095. // `stage_apply.rs` file would be actively misleading (#173).
  2096. if (isQualified) return null;
  2097. return { node: results[0]!.node, note: '' };
  2098. }
  2099. /**
  2100. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  2101. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  2102. */
  2103. private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  2104. let results = cg.searchNodes(symbol, { limit: 50 });
  2105. // Mirror the fallback in `findSymbol` for qualified queries — FTS
  2106. // strips colons, so a module-qualified lookup needs a second pass
  2107. // by the bare last part.
  2108. if (results.length === 0 && /[.\/]|::/.test(symbol)) {
  2109. const tail = lastQualifierPart(symbol);
  2110. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
  2111. }
  2112. if (results.length === 0) {
  2113. return { nodes: [], note: '' };
  2114. }
  2115. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  2116. if (exactMatches.length <= 1) {
  2117. const node = exactMatches[0]?.node ?? results[0]!.node;
  2118. return { nodes: [node], note: '' };
  2119. }
  2120. const locations = exactMatches.map(r =>
  2121. `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
  2122. );
  2123. const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
  2124. return { nodes: exactMatches.map(r => r.node), note };
  2125. }
  2126. /**
  2127. * Truncate output if it exceeds the maximum length
  2128. */
  2129. private truncateOutput(text: string): string {
  2130. if (text.length <= MAX_OUTPUT_LENGTH) return text;
  2131. const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
  2132. const lastNewline = truncated.lastIndexOf('\n');
  2133. const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
  2134. return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
  2135. }
  2136. // =========================================================================
  2137. // Formatting helpers (compact by default to reduce context usage)
  2138. // =========================================================================
  2139. private formatSearchResults(results: SearchResult[]): string {
  2140. const lines: string[] = [`## Search Results (${results.length} found)`, ''];
  2141. for (const result of results) {
  2142. const { node } = result;
  2143. const location = node.startLine ? `:${node.startLine}` : '';
  2144. // Compact format: one line per result with key info
  2145. lines.push(`### ${node.name} (${node.kind})`);
  2146. lines.push(`${node.filePath}${location}`);
  2147. if (node.signature) lines.push(`\`${node.signature}\``);
  2148. lines.push('');
  2149. }
  2150. return lines.join('\n');
  2151. }
  2152. private formatNodeList(nodes: Node[], title: string): string {
  2153. const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
  2154. for (const node of nodes) {
  2155. const location = node.startLine ? `:${node.startLine}` : '';
  2156. // Compact: just name, kind, location
  2157. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
  2158. }
  2159. return lines.join('\n');
  2160. }
  2161. private formatImpact(symbol: string, impact: Subgraph): string {
  2162. const nodeCount = impact.nodes.size;
  2163. // Compact format: just list affected symbols grouped by file
  2164. const lines: string[] = [
  2165. `## Impact: "${symbol}" affects ${nodeCount} symbols`,
  2166. '',
  2167. ];
  2168. // Group by file
  2169. const byFile = new Map<string, Node[]>();
  2170. for (const node of impact.nodes.values()) {
  2171. const existing = byFile.get(node.filePath) || [];
  2172. existing.push(node);
  2173. byFile.set(node.filePath, existing);
  2174. }
  2175. for (const [file, nodes] of byFile) {
  2176. lines.push(`**${file}:**`);
  2177. // Compact: inline list
  2178. const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  2179. lines.push(nodeList);
  2180. lines.push('');
  2181. }
  2182. return lines.join('\n');
  2183. }
  2184. /**
  2185. * Build a compact structural outline of a container symbol from its
  2186. * indexed children (methods, fields, properties, …) — name, kind,
  2187. * line number, and signature — so the agent gets the shape of a class
  2188. * without the full source of every method. Returns '' when the container
  2189. * has no indexed children, so the caller can fall back to full source.
  2190. */
  2191. private buildContainerOutline(cg: CodeGraph, node: Node): string {
  2192. const children = cg.getChildren(node.id)
  2193. .filter(c => c.kind !== 'import' && c.kind !== 'export')
  2194. .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
  2195. if (children.length === 0) return '';
  2196. const lines = [`**Members (${children.length}):**`, ''];
  2197. for (const c of children) {
  2198. const loc = c.startLine ? `:${c.startLine}` : '';
  2199. const sig = c.signature ? ` — \`${c.signature}\`` : '';
  2200. lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
  2201. }
  2202. return lines.join('\n');
  2203. }
  2204. private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
  2205. const location = node.startLine ? `:${node.startLine}` : '';
  2206. const lines: string[] = [
  2207. `## ${node.name} (${node.kind})`,
  2208. '',
  2209. `**Location:** ${node.filePath}${location}`,
  2210. ];
  2211. if (node.signature) {
  2212. lines.push(`**Signature:** \`${node.signature}\``);
  2213. }
  2214. // Only include docstring if it's short and useful
  2215. if (node.docstring && node.docstring.length < 200) {
  2216. lines.push('', node.docstring);
  2217. }
  2218. if (outline) {
  2219. lines.push('', outline, '',
  2220. `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
  2221. } else if (code) {
  2222. // Line-numbered (cat -n style, like codegraph_explore and Read) so the
  2223. // agent can cite/edit exact lines without re-Reading the file for them.
  2224. const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
  2225. lines.push('', '```' + node.language, numbered, '```');
  2226. }
  2227. return lines.join('\n');
  2228. }
  2229. private formatTaskContext(context: TaskContext): string {
  2230. return context.summary || 'No context found';
  2231. }
  2232. private textResult(text: string): ToolResult {
  2233. return {
  2234. content: [{ type: 'text', text }],
  2235. };
  2236. }
  2237. private errorResult(message: string): ToolResult {
  2238. return {
  2239. content: [{ type: 'text', text: `Error: ${message}` }],
  2240. isError: true,
  2241. };
  2242. }
  2243. }