tools.ts 133 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190
  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 {
  8. detectWorktreeIndexMismatch,
  9. worktreeMismatchWarning,
  10. worktreeMismatchNotice,
  11. type WorktreeIndexMismatch,
  12. } from '../sync/worktree';
  13. import type { PendingFile } from '../sync';
  14. import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
  15. import { createHash } from 'crypto';
  16. import {
  17. constants as fsConstants,
  18. closeSync,
  19. existsSync,
  20. lstatSync,
  21. openSync,
  22. readFileSync,
  23. statSync,
  24. writeSync,
  25. } from 'fs';
  26. import { clamp, validatePathWithinRoot, validateProjectPath } from '../utils';
  27. import { isGeneratedFile } from '../extraction/generated-detection';
  28. import { tmpdir } from 'os';
  29. import * as pathModule from 'path';
  30. import { join, resolve as resolvePath } from 'path';
  31. /** Maximum output length to prevent context bloat (characters) */
  32. const MAX_OUTPUT_LENGTH = 15000;
  33. /**
  34. * Maximum length for free-form string inputs (query, task, symbol).
  35. * Bounds memory and CPU when a buggy or hostile MCP client sends a
  36. * huge payload — without this an attacker could ship a 100MB string
  37. * and force a full FTS5 scan / OOM the server. 10 000 characters is
  38. * far beyond any realistic legitimate query.
  39. */
  40. const MAX_INPUT_LENGTH = 10_000;
  41. /**
  42. * Maximum length for path-like string inputs (projectPath, path
  43. * filter, glob pattern). Paths beyond a few thousand chars are
  44. * never legitimate and signal abuse or a bug upstream.
  45. */
  46. const MAX_PATH_LENGTH = 4_096;
  47. /**
  48. * Rust path roots that have no file-system equivalent — `crate` is the
  49. * current crate, `super` is the parent module, `self` is the current
  50. * module. Used by `matchesSymbol` to strip these before file-path
  51. * matching so `crate::configurator::stage_apply::run` resolves the
  52. * same as `configurator::stage_apply::run`.
  53. */
  54. const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
  55. /**
  56. * Node kinds that contain other symbols. For these, `codegraph_node` with
  57. * `includeCode=true` returns a structural outline (member names + signatures
  58. * + line numbers) instead of the full body, which for a large class is a
  59. * multi-thousand-character wall of source that bloats the agent's context.
  60. */
  61. const CONTAINER_NODE_KINDS = new Set<NodeKind>([
  62. 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
  63. ]);
  64. /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
  65. function lastQualifierPart(symbol: string): string {
  66. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  67. return parts[parts.length - 1] ?? symbol;
  68. }
  69. /**
  70. * Calculate the recommended number of codegraph_explore calls based on project size.
  71. * Larger codebases need more exploration calls to cover their surface area,
  72. * but smaller ones should use fewer to avoid unnecessary overhead.
  73. */
  74. export function getExploreBudget(fileCount: number): number {
  75. if (fileCount < 500) return 1;
  76. if (fileCount < 5000) return 2;
  77. if (fileCount < 15000) return 3;
  78. if (fileCount < 25000) return 4;
  79. return 5;
  80. }
  81. /**
  82. * Adaptive output budget for `codegraph_explore`, scaled to project size.
  83. *
  84. * Smaller codebases get a tighter total cap, fewer default files, smaller
  85. * per-file cap, and tighter clustering — so a focused query on a 100-file
  86. * project doesn't dump a whole file's worth of source into the agent's
  87. * context. Larger codebases keep the generous defaults because the
  88. * agent's native discovery cost (grep + find + many Reads) genuinely
  89. * dwarfs a fat explore call at that scale.
  90. *
  91. * Meta-text (relationships map, "additional relevant files" list,
  92. * completeness signal, budget note) is gated off for tiny projects
  93. * where one rich call is the whole story and the extra prose is just
  94. * overhead.
  95. *
  96. * Tier breakpoints mirror `getExploreBudget` so a project sits in the
  97. * same tier across both knobs.
  98. */
  99. export interface ExploreOutputBudget {
  100. /** Hard cap on total output characters. */
  101. maxOutputChars: number;
  102. /** Default `maxFiles` when the caller didn't specify one. */
  103. defaultMaxFiles: number;
  104. /** Cap on contiguous source returned per file (across all its clusters). */
  105. maxCharsPerFile: number;
  106. /** Cluster gap threshold in lines — tighter clustering on small projects. */
  107. gapThreshold: number;
  108. /** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */
  109. maxSymbolsInFileHeader: number;
  110. /** Max edges shown per relationship kind in the Relationships section. */
  111. maxEdgesPerRelationshipKind: number;
  112. /** Include the "Relationships" section. */
  113. includeRelationships: boolean;
  114. /** Include the "Additional relevant files (not shown)" trailing list. */
  115. includeAdditionalFiles: boolean;
  116. /** Include the "Complete source code is included above…" reminder. */
  117. includeCompletenessSignal: boolean;
  118. /** Include the explore-budget reminder at the end. */
  119. includeBudgetNote: boolean;
  120. /**
  121. * Hard-drop test/spec/icon/i18n files from the relevant-file set unless
  122. * the query itself mentions tests. Today they're only deprioritized in
  123. * the sort, which on tiny repos still lets one slip into the top N (e.g.
  124. * cobra's `command_test.go` displaced `args.go` and contributed ~10KB of
  125. * pure noise to "How does cobra parse commands?"). Off by default; on
  126. * for the very-tiny tier where one slip dominates the budget.
  127. */
  128. excludeLowValueFiles: boolean;
  129. }
  130. export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
  131. if (fileCount < 150) {
  132. return {
  133. // ITER3: revert iter2's aggressive body shrink (forced Read fallback —
  134. // the per-file 2.5K cap pushed the agent to Read instead of node).
  135. // Back to the iter1 shape (13K/4/3.8K) but keep the test-file
  136. // hard-exclude. The cost lever for this tier lives in handleContext
  137. // (steering the agent to stop after 1-2 calls), not in this budget.
  138. maxOutputChars: 13000,
  139. defaultMaxFiles: 4,
  140. maxCharsPerFile: 3800,
  141. gapThreshold: 7,
  142. maxSymbolsInFileHeader: 5,
  143. maxEdgesPerRelationshipKind: 4,
  144. includeRelationships: false,
  145. includeAdditionalFiles: false,
  146. includeCompletenessSignal: false,
  147. includeBudgetNote: false,
  148. excludeLowValueFiles: true,
  149. };
  150. }
  151. if (fileCount < 500) {
  152. return {
  153. // ITER3: same revert/keep-filter pattern as <150.
  154. maxOutputChars: 18000,
  155. defaultMaxFiles: 5,
  156. maxCharsPerFile: 3800,
  157. gapThreshold: 8,
  158. maxSymbolsInFileHeader: 6,
  159. maxEdgesPerRelationshipKind: 6,
  160. includeRelationships: false,
  161. includeAdditionalFiles: false,
  162. includeCompletenessSignal: false,
  163. includeBudgetNote: false,
  164. excludeLowValueFiles: true,
  165. };
  166. }
  167. if (fileCount < 5000) {
  168. return {
  169. // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
  170. // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
  171. // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
  172. // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
  173. // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
  174. maxOutputChars: 28000,
  175. defaultMaxFiles: 10,
  176. maxCharsPerFile: 6500,
  177. gapThreshold: 12,
  178. maxSymbolsInFileHeader: 10,
  179. maxEdgesPerRelationshipKind: 10,
  180. includeRelationships: true,
  181. includeAdditionalFiles: true,
  182. includeCompletenessSignal: true,
  183. includeBudgetNote: true,
  184. excludeLowValueFiles: false,
  185. };
  186. }
  187. if (fileCount < 15000) {
  188. return {
  189. maxOutputChars: 35000,
  190. defaultMaxFiles: 12,
  191. maxCharsPerFile: 7000,
  192. gapThreshold: 15,
  193. maxSymbolsInFileHeader: 15,
  194. maxEdgesPerRelationshipKind: 15,
  195. includeRelationships: true,
  196. includeAdditionalFiles: true,
  197. includeCompletenessSignal: true,
  198. includeBudgetNote: true,
  199. excludeLowValueFiles: false,
  200. };
  201. }
  202. return {
  203. maxOutputChars: 38000,
  204. defaultMaxFiles: 14,
  205. maxCharsPerFile: 7000,
  206. gapThreshold: 15,
  207. maxSymbolsInFileHeader: 15,
  208. maxEdgesPerRelationshipKind: 15,
  209. includeRelationships: true,
  210. includeAdditionalFiles: true,
  211. includeCompletenessSignal: true,
  212. includeBudgetNote: true,
  213. excludeLowValueFiles: false,
  214. };
  215. }
  216. /**
  217. * Whether `codegraph_explore` should prefix source lines with their line
  218. * numbers (cat -n style: `<num>\t<code>`).
  219. *
  220. * Line numbers let the agent cite `file:line` straight from the explore
  221. * payload instead of re-Reading the file just to find a line number — the
  222. * dominant residual cost on precise-tracing questions (#185 follow-up).
  223. *
  224. * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
  225. * A/B harness to measure the payload-cost vs. read-savings tradeoff).
  226. */
  227. function exploreLineNumbersEnabled(): boolean {
  228. return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
  229. }
  230. /**
  231. * Prefix each line of a source slice with its 1-based line number, matching
  232. * the Read tool's `cat -n` convention (number + tab) so the agent treats it
  233. * the same way it treats Read output.
  234. *
  235. * @param slice contiguous source text (already extracted from the file)
  236. * @param firstLineNumber the 1-based line number of the slice's first line
  237. */
  238. function numberSourceLines(slice: string, firstLineNumber: number): string {
  239. const out: string[] = [];
  240. const split = slice.split('\n');
  241. for (let i = 0; i < split.length; i++) {
  242. out.push(`${firstLineNumber + i}\t${split[i]}`);
  243. }
  244. return out.join('\n');
  245. }
  246. /**
  247. * Mark a Claude session as having consulted MCP tools.
  248. * This enables Grep/Glob/Bash commands that would otherwise be blocked.
  249. *
  250. * Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
  251. * tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
  252. * machine any other local user can pre-create `codegraph-consulted-<hash>` as
  253. * a symlink pointing at a file the victim owns. The old `writeFileSync` would
  254. * happily follow that link and overwrite the target's contents with the ISO
  255. * timestamp string (CWE-59). The session-id hash provides the predictability
  256. * gate, but it's defense-in-depth: if a session id ever surfaces in logs,
  257. * argv, or telemetry the attack becomes trivial, and the right fix is to not
  258. * follow links from /tmp paths in the first place.
  259. */
  260. function markSessionConsulted(sessionId: string): void {
  261. try {
  262. const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
  263. const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
  264. // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
  265. // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
  266. // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
  267. // drops it and openSync would follow the link. This lstat check closes that
  268. // gap cross-platform; ENOENT (path is free) falls through to create it.
  269. try {
  270. if (lstatSync(markerPath).isSymbolicLink()) return;
  271. } catch {
  272. // No existing entry (or stat failed) — nothing to refuse; proceed.
  273. }
  274. // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
  275. // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
  276. // mode 0o600 prevents readback by other local users (the marker payload is
  277. // benign, but narrowing the exposure costs nothing).
  278. const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
  279. const fd = openSync(markerPath, flags, 0o600);
  280. try {
  281. writeSync(fd, new Date().toISOString());
  282. } finally {
  283. closeSync(fd);
  284. }
  285. } catch {
  286. // Silently fail - don't break MCP on marker write failure. ELOOP from a
  287. // planted symlink lands here too, which is the intended behavior: refuse
  288. // to write rather than overwrite an attacker-chosen target.
  289. }
  290. }
  291. /**
  292. * Per-file staleness banner emitted at the top of a tool response when the
  293. * file watcher has pending events for files referenced by the response.
  294. * The agent uses this to fall back to Read for those specific files
  295. * without waiting for the debounced sync (issue #403).
  296. */
  297. export function formatStaleBanner(stale: PendingFile[]): string {
  298. const now = Date.now();
  299. const lines = stale.map((p) => {
  300. const ageMs = Math.max(0, now - p.lastSeenMs);
  301. const label = p.indexing ? 'indexing in progress' : 'pending sync';
  302. return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
  303. });
  304. return (
  305. '⚠️ Some files referenced below were edited since the last index sync — ' +
  306. 'their codegraph entries may be stale:\n' +
  307. lines.join('\n') +
  308. '\nFor accurate content of those specific files, Read them directly. ' +
  309. 'The rest of this response is fresh.'
  310. );
  311. }
  312. /**
  313. * Compact footer listing pending files that are NOT referenced in this
  314. * response. Gives the agent a complete project-wide freshness picture
  315. * without bloating the main banner.
  316. */
  317. export function formatStaleFooter(stale: PendingFile[]): string {
  318. const MAX = 5;
  319. const now = Date.now();
  320. const shown = stale.slice(0, MAX);
  321. const lines = shown.map((p) => {
  322. const ageMs = Math.max(0, now - p.lastSeenMs);
  323. return ` - ${p.path} (edited ${ageMs}ms ago)`;
  324. });
  325. const more = stale.length > MAX ? `\n - …and ${stale.length - MAX} more` : '';
  326. return (
  327. `(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
  328. `sync but were not referenced above:\n${lines.join('\n')}${more})`
  329. );
  330. }
  331. /**
  332. * MCP Tool definition
  333. */
  334. export interface ToolDefinition {
  335. name: string;
  336. description: string;
  337. inputSchema: {
  338. type: 'object';
  339. properties: Record<string, PropertySchema>;
  340. required?: string[];
  341. };
  342. }
  343. interface PropertySchema {
  344. type: string;
  345. description: string;
  346. enum?: string[];
  347. default?: unknown;
  348. }
  349. /**
  350. * Tool execution result
  351. */
  352. export interface ToolResult {
  353. content: Array<{
  354. type: 'text';
  355. text: string;
  356. }>;
  357. isError?: boolean;
  358. }
  359. /**
  360. * Common projectPath property for cross-project queries
  361. */
  362. const projectPathProperty: PropertySchema = {
  363. type: 'string',
  364. description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
  365. };
  366. /**
  367. * All CodeGraph MCP tools
  368. *
  369. * Designed for minimal context usage - use codegraph_context as the primary tool,
  370. * and only use other tools for targeted follow-up queries.
  371. *
  372. * All tools support cross-project queries via the optional `projectPath` parameter.
  373. */
  374. export const tools: ToolDefinition[] = [
  375. {
  376. name: 'codegraph_search',
  377. description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
  378. inputSchema: {
  379. type: 'object',
  380. properties: {
  381. query: {
  382. type: 'string',
  383. description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
  384. },
  385. kind: {
  386. type: 'string',
  387. description: 'Filter by node kind',
  388. enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
  389. },
  390. limit: {
  391. type: 'number',
  392. description: 'Maximum results (default: 10)',
  393. default: 10,
  394. },
  395. projectPath: projectPathProperty,
  396. },
  397. required: ['query'],
  398. },
  399. },
  400. {
  401. name: 'codegraph_context',
  402. description: 'PRIMARY TOOL — call FIRST for any "how does X work"/architecture/bug question. Returns entry points + related symbols + key code in one call; usually answers without further search/Read/Grep. Provides CODE context, not product requirements.',
  403. inputSchema: {
  404. type: 'object',
  405. properties: {
  406. task: {
  407. type: 'string',
  408. description: 'Description of the task, bug, or feature to build context for',
  409. },
  410. maxNodes: {
  411. type: 'number',
  412. description: 'Maximum symbols to include (default: 20)',
  413. default: 20,
  414. },
  415. includeCode: {
  416. type: 'boolean',
  417. description: 'Include code snippets for key symbols (default: true)',
  418. default: true,
  419. },
  420. projectPath: projectPathProperty,
  421. },
  422. required: ['task'],
  423. },
  424. },
  425. {
  426. name: 'codegraph_callers',
  427. description: 'List functions that call <symbol>. For deep flow use codegraph_trace.',
  428. inputSchema: {
  429. type: 'object',
  430. properties: {
  431. symbol: {
  432. type: 'string',
  433. description: 'Name of the function, method, or class to find callers for',
  434. },
  435. limit: {
  436. type: 'number',
  437. description: 'Maximum number of callers to return (default: 20)',
  438. default: 20,
  439. },
  440. projectPath: projectPathProperty,
  441. },
  442. required: ['symbol'],
  443. },
  444. },
  445. {
  446. name: 'codegraph_callees',
  447. description: 'List functions that <symbol> calls. For deep flow use codegraph_trace.',
  448. inputSchema: {
  449. type: 'object',
  450. properties: {
  451. symbol: {
  452. type: 'string',
  453. description: 'Name of the function, method, or class to find callees for',
  454. },
  455. limit: {
  456. type: 'number',
  457. description: 'Maximum number of callees to return (default: 20)',
  458. default: 20,
  459. },
  460. projectPath: projectPathProperty,
  461. },
  462. required: ['symbol'],
  463. },
  464. },
  465. {
  466. name: 'codegraph_impact',
  467. description: 'List symbols affected by changing <symbol>. Use before a refactor.',
  468. inputSchema: {
  469. type: 'object',
  470. properties: {
  471. symbol: {
  472. type: 'string',
  473. description: 'Name of the symbol to analyze impact for',
  474. },
  475. depth: {
  476. type: 'number',
  477. description: 'How many levels of dependencies to traverse (default: 2)',
  478. default: 2,
  479. },
  480. projectPath: projectPathProperty,
  481. },
  482. required: ['symbol'],
  483. },
  484. },
  485. {
  486. name: 'codegraph_node',
  487. description: 'One symbol\'s location, signature, callers/callees trail. includeCode=true returns the verbatim body. Use codegraph_trace for full paths instead of chaining nodes.',
  488. inputSchema: {
  489. type: 'object',
  490. properties: {
  491. symbol: {
  492. type: 'string',
  493. description: 'Name of the symbol to get details for',
  494. },
  495. includeCode: {
  496. type: 'boolean',
  497. description: 'Include full source code (default: false to minimize context)',
  498. default: false,
  499. },
  500. projectPath: projectPathProperty,
  501. },
  502. required: ['symbol'],
  503. },
  504. },
  505. {
  506. name: 'codegraph_explore',
  507. description: 'Source of SEVERAL related symbols grouped by file, in one capped call. Query is a bag of symbol/file names (not a question). Returned source is verbatim Read-equivalent — do not re-open shown files. Prefer over chained codegraph_node.',
  508. inputSchema: {
  509. type: 'object',
  510. properties: {
  511. query: {
  512. type: 'string',
  513. 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.',
  514. },
  515. maxFiles: {
  516. type: 'number',
  517. description: 'Maximum number of files to include source code from (default: 12)',
  518. default: 12,
  519. },
  520. projectPath: projectPathProperty,
  521. },
  522. required: ['query'],
  523. },
  524. },
  525. {
  526. name: 'codegraph_status',
  527. description: 'Index health check (files / nodes / edges). Skip unless debugging.',
  528. inputSchema: {
  529. type: 'object',
  530. properties: {
  531. projectPath: projectPathProperty,
  532. },
  533. },
  534. },
  535. {
  536. name: 'codegraph_files',
  537. description: 'Indexed file tree with language + symbol counts. Faster than Glob for project layout.',
  538. inputSchema: {
  539. type: 'object',
  540. properties: {
  541. path: {
  542. type: 'string',
  543. description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
  544. },
  545. pattern: {
  546. type: 'string',
  547. description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
  548. },
  549. format: {
  550. type: 'string',
  551. description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
  552. enum: ['tree', 'flat', 'grouped'],
  553. default: 'tree',
  554. },
  555. includeMetadata: {
  556. type: 'boolean',
  557. description: 'Include file metadata like language and symbol count (default: true)',
  558. default: true,
  559. },
  560. maxDepth: {
  561. type: 'number',
  562. description: 'Maximum directory depth to show (default: unlimited)',
  563. },
  564. projectPath: projectPathProperty,
  565. },
  566. },
  567. },
  568. {
  569. name: 'codegraph_trace',
  570. description: 'Call path between two symbols — "how does <from> reach <to>?" Returns the chain with each hop\'s body inlined plus the destination\'s callees, in ONE call. Ideal for flow questions (update→render, request→handler, QuerySet→SQL). If no static path exists the chain broke at dynamic dispatch — the failure response inlines both endpoints + their TO-file siblings.',
  571. inputSchema: {
  572. type: 'object',
  573. properties: {
  574. from: {
  575. type: 'string',
  576. description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
  577. },
  578. to: {
  579. type: 'string',
  580. description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
  581. },
  582. projectPath: projectPathProperty,
  583. },
  584. required: ['from', 'to'],
  585. },
  586. },
  587. ];
  588. /**
  589. * Tool handler that executes tools against a CodeGraph instance
  590. *
  591. * Supports cross-project queries via the projectPath parameter.
  592. * Other projects are opened on-demand and cached for performance.
  593. */
  594. export class ToolHandler {
  595. // Cache of opened CodeGraph instances for cross-project queries
  596. private projectCache: Map<string, CodeGraph> = new Map();
  597. // The directory the server last searched for a default project. Surfaced in
  598. // the "not initialized" error so users can see why detection missed.
  599. private defaultProjectHint: string | null = null;
  600. // Per-start-path cache of the git worktree/index mismatch (issue #155). The
  601. // mismatch is a fixed property of (where the request came from → which
  602. // .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
  603. // once and every later tool call reuses the result — never shelling out to
  604. // git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
  605. private worktreeMismatchCache: Map<string, WorktreeIndexMismatch | null> = new Map();
  606. constructor(private cg: CodeGraph | null) {}
  607. /**
  608. * Update the default CodeGraph instance (e.g. after lazy initialization)
  609. */
  610. setDefaultCodeGraph(cg: CodeGraph): void {
  611. this.cg = cg;
  612. }
  613. /**
  614. * Record the directory the server tried to resolve the default project from.
  615. * Used only to make the "no default project" error actionable.
  616. */
  617. setDefaultProjectHint(searchedPath: string): void {
  618. this.defaultProjectHint = searchedPath;
  619. }
  620. /**
  621. * Whether a default CodeGraph instance is available
  622. */
  623. hasDefaultCodeGraph(): boolean {
  624. return this.cg !== null;
  625. }
  626. /**
  627. * Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
  628. * env var (comma-separated short names, e.g. "trace,search,node,context").
  629. * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
  630. * trim the tool surface without rebuilding the client config; the ablated
  631. * tool is then truly absent from ListTools rather than merely denied on call.
  632. * Matching is on the short form, so "trace" and "codegraph_trace" both work.
  633. */
  634. private toolAllowlist(): Set<string> | null {
  635. const raw = process.env.CODEGRAPH_MCP_TOOLS;
  636. if (!raw || !raw.trim()) return null;
  637. const short = (s: string) => s.trim().replace(/^codegraph_/, '');
  638. const set = new Set(raw.split(',').map(short).filter(Boolean));
  639. return set.size ? set : null;
  640. }
  641. /** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
  642. private isToolAllowed(name: string): boolean {
  643. const allow = this.toolAllowlist();
  644. return !allow || allow.has(name.replace(/^codegraph_/, ''));
  645. }
  646. /**
  647. * Get tool definitions with dynamic descriptions based on project size.
  648. * The codegraph_explore tool description includes a budget recommendation
  649. * scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
  650. * allowlist so a trimmed surface is reflected in ListTools.
  651. */
  652. getTools(): ToolDefinition[] {
  653. const allow = this.toolAllowlist();
  654. let visible = allow
  655. ? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
  656. : tools;
  657. if (!this.cg) return visible;
  658. try {
  659. const stats = this.cg.getStats();
  660. const budget = getExploreBudget(stats.fileCount);
  661. // Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
  662. // files, only expose the 5 core tools (search, context, node,
  663. // explore, trace). The 5 omitted tools (callers, callees, impact,
  664. // status, files) reduce to one grep at this scale.
  665. //
  666. // n=2 audits ruled out cutting below 5 tools:
  667. // - 3-tool gate (search + context + trace): cost regressed on
  668. // cobra/ky/sinatra. The agent fell back to raw Reads to cover
  669. // what codegraph_node + codegraph_explore would have answered.
  670. // - 1-tool gate (search only): catastrophic regression — express
  671. // went from -43% WIN to +107% LOSS. With only search, the agent
  672. // can't navigate the call graph structurally and reads everything.
  673. //
  674. // 5 is the empirical lower bound. Tools beyond search/context/
  675. // node/explore/trace pay overhead that the agent doesn't recoup
  676. // on tiny-repo flow questions.
  677. // ITER4: raise threshold 150 → 500 so single-file frameworks
  678. // (sinatra at 159, slim_framework around 200) also get the
  679. // 5-tool surface. The empirical 5-tool floor was set on <150
  680. // probes; iter3 measurement showed sinatra is structurally the
  681. // SAME problem as cobra (single-file WITHOUT-arm Read wins),
  682. // so it deserves the same gating.
  683. const TINY_REPO_FILE_THRESHOLD = 500;
  684. const TINY_REPO_CORE_TOOLS = new Set([
  685. 'codegraph_search',
  686. 'codegraph_context',
  687. 'codegraph_node',
  688. 'codegraph_explore',
  689. 'codegraph_trace',
  690. ]);
  691. if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
  692. visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
  693. }
  694. return visible.map(tool => {
  695. if (tool.name === 'codegraph_explore') {
  696. return {
  697. ...tool,
  698. description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
  699. };
  700. }
  701. return tool;
  702. });
  703. } catch {
  704. return visible;
  705. }
  706. }
  707. /**
  708. * Get CodeGraph instance for a project
  709. *
  710. * If projectPath is provided, opens that project's CodeGraph (cached).
  711. * Otherwise returns the default CodeGraph instance.
  712. *
  713. * Walks up parent directories to find the nearest .codegraph/ folder,
  714. * similar to how git finds .git/ directories.
  715. */
  716. private getCodeGraph(projectPath?: string): CodeGraph {
  717. if (!projectPath) {
  718. if (!this.cg) {
  719. const searched = this.defaultProjectHint ?? process.cwd();
  720. throw new Error(
  721. 'No CodeGraph project is loaded for this session.\n' +
  722. `Searched for a .codegraph/ directory starting from: ${searched}\n` +
  723. 'The index is likely fine — this is a working-directory detection issue: ' +
  724. "the MCP client launched the server outside your project and didn't report the " +
  725. 'workspace root. Fix it either way:\n' +
  726. ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
  727. ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
  728. );
  729. }
  730. return this.cg;
  731. }
  732. // Check cache first (using original path as key)
  733. if (this.projectCache.has(projectPath)) {
  734. return this.projectCache.get(projectPath)!;
  735. }
  736. // Reject sensitive system directories before opening. Only validate a
  737. // path that actually exists — a nested or not-yet-created sub-path of a
  738. // real project must still be allowed to resolve UP to its .codegraph/
  739. // root below (issue #238), so we don't run the existence-checking
  740. // validator on paths that are meant to walk up.
  741. if (existsSync(projectPath)) {
  742. const pathError = validateProjectPath(projectPath);
  743. if (pathError) {
  744. throw new Error(pathError);
  745. }
  746. }
  747. // Walk up parent directories to find nearest .codegraph/
  748. const resolvedRoot = findNearestCodeGraphRoot(projectPath);
  749. if (!resolvedRoot) {
  750. throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
  751. }
  752. // If the path resolves to the default project, reuse the already-open
  753. // default instance rather than opening a SECOND connection to the same DB.
  754. // A duplicate connection serializes reads against the watcher's auto-sync
  755. // writes; on the wasm backend (no WAL) that surfaces as intermittent
  756. // "database is locked" on concurrent tool calls. See issue #238. Deliberately
  757. // not cached under projectPath — the server owns and closes the default
  758. // instance, so routing it through projectCache.closeAll() would double-close it.
  759. if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
  760. return this.cg;
  761. }
  762. // Check if we already have this resolved root cached (different path, same project)
  763. if (this.projectCache.has(resolvedRoot)) {
  764. const cg = this.projectCache.get(resolvedRoot)!;
  765. // Cache under original path too for faster future lookups
  766. this.projectCache.set(projectPath, cg);
  767. return cg;
  768. }
  769. // Open and cache under both paths
  770. const cg = CodeGraph.openSync(resolvedRoot);
  771. this.projectCache.set(resolvedRoot, cg);
  772. if (projectPath !== resolvedRoot) {
  773. this.projectCache.set(projectPath, cg);
  774. }
  775. return cg;
  776. }
  777. /**
  778. * Close all cached project connections
  779. */
  780. closeAll(): void {
  781. for (const cg of this.projectCache.values()) {
  782. cg.close();
  783. }
  784. this.projectCache.clear();
  785. this.worktreeMismatchCache.clear();
  786. }
  787. /**
  788. * Validate that a value is a non-empty string within length bounds.
  789. *
  790. * The `maxLength` cap protects against MCP clients that ship huge
  791. * payloads (10MB+ query strings either by accident or maliciously).
  792. * Without this, a single oversized input can pin the FTS5 index or
  793. * exhaust memory before any real work runs.
  794. */
  795. private validateString(
  796. value: unknown,
  797. name: string,
  798. maxLength: number = MAX_INPUT_LENGTH
  799. ): string | ToolResult {
  800. if (typeof value !== 'string' || value.length === 0) {
  801. return this.errorResult(`${name} must be a non-empty string`);
  802. }
  803. if (value.length > maxLength) {
  804. return this.errorResult(
  805. `${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`
  806. );
  807. }
  808. return value;
  809. }
  810. /**
  811. * Validate an optional path-like string input. Returns the value if
  812. * valid (or undefined), or a ToolResult with the error.
  813. */
  814. private validateOptionalPath(
  815. value: unknown,
  816. name: string
  817. ): string | undefined | ToolResult {
  818. if (value === undefined || value === null) return undefined;
  819. if (typeof value !== 'string') {
  820. return this.errorResult(`${name} must be a string`);
  821. }
  822. if (value.length > MAX_PATH_LENGTH) {
  823. return this.errorResult(
  824. `${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`
  825. );
  826. }
  827. return value;
  828. }
  829. /**
  830. * Cached git worktree/index mismatch for a tool call's effective project.
  831. *
  832. * The "effective project" is what the request targets: an explicit
  833. * `projectPath` arg, else the directory the server resolved its default
  834. * project from (`defaultProjectHint`), else cwd. Memoized per start path —
  835. * see `worktreeMismatchCache`. Best-effort: if the project can't be resolved
  836. * (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
  837. * broken by this check.
  838. */
  839. private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null {
  840. const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
  841. const cached = this.worktreeMismatchCache.get(startPath);
  842. if (cached !== undefined) return cached;
  843. let mismatch: WorktreeIndexMismatch | null = null;
  844. try {
  845. mismatch = detectWorktreeIndexMismatch(startPath, this.getCodeGraph(projectPath).getProjectRoot());
  846. } catch {
  847. // No resolvable project (or any other resolution error) → nothing to warn.
  848. mismatch = null;
  849. }
  850. this.worktreeMismatchCache.set(startPath, mismatch);
  851. return mismatch;
  852. }
  853. /**
  854. * Prefix a successful read-tool result with a compact worktree-mismatch
  855. * notice when the resolved index belongs to a different git working tree than
  856. * the caller's (issue #155). Without this, an agent in a nested worktree
  857. * silently trusts main-branch results. No-op on error results and when there
  858. * is no mismatch. `codegraph_status` is excluded — it embeds its own verbose
  859. * warning — so it stays out of this path.
  860. */
  861. private withWorktreeNotice(result: ToolResult, projectPath?: string): ToolResult {
  862. if (result.isError) return result;
  863. const mismatch = this.worktreeMismatchFor(projectPath);
  864. if (!mismatch) return result;
  865. const notice = worktreeMismatchNotice(mismatch);
  866. const [first, ...rest] = result.content;
  867. if (first && first.type === 'text') {
  868. return { ...result, content: [{ type: 'text', text: `${notice}\n\n${first.text}` }, ...rest] };
  869. }
  870. return result;
  871. }
  872. /**
  873. * Annotate a successful read-tool result with per-file staleness — the
  874. * non-blocking answer to issue #403. The file watcher tracks every event
  875. * it sees per path; here we intersect "files referenced in this response"
  876. * against that pending set and prepend a compact banner so the agent can
  877. * fall back to Read for those *specific* files without waiting for the
  878. * debounced sync to fire. Other pending files in the project (not
  879. * referenced by this response) get a small footer so the agent has a
  880. * complete picture without bloating the banner.
  881. *
  882. * Cost when nothing is pending — the common case — is one boolean check.
  883. * No I/O, no parsing of markdown beyond a per-pending-file substring scan.
  884. */
  885. private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult {
  886. if (result.isError) return result;
  887. let cg: CodeGraph;
  888. try {
  889. cg = this.getCodeGraph(projectPath);
  890. } catch {
  891. return result; // no default project — leave as is
  892. }
  893. // Cross-project `projectPath` calls open a cached CodeGraph WITHOUT a
  894. // watcher (watchers are only attached to the default session project).
  895. // When the cross-project path happens to be the same project as the
  896. // default cg, the cached instance is the wrong one — its pendingFiles is
  897. // permanently empty. Detect the equal-path case and prefer the default
  898. // cg so the staleness signal still fires when an agent passes the
  899. // explicit projectPath form of its own project.
  900. if (this.cg && cg !== this.cg) {
  901. try {
  902. const sameProject =
  903. resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot());
  904. if (sameProject) cg = this.cg;
  905. } catch {
  906. /* getProjectRoot may throw on a closed instance — leave cg as is */
  907. }
  908. }
  909. // Defensive: some test fakes inject a partial CodeGraph stub without the
  910. // newer pending-files API. Treat missing/throwing as "no pending files."
  911. let pending: PendingFile[] = [];
  912. try {
  913. pending = cg.getPendingFiles?.() ?? [];
  914. } catch {
  915. return result;
  916. }
  917. if (pending.length === 0) return result;
  918. const [first, ...rest] = result.content;
  919. if (!first || first.type !== 'text') return result;
  920. const text = first.text;
  921. const inResponse: PendingFile[] = [];
  922. const elsewhere: PendingFile[] = [];
  923. for (const p of pending) {
  924. // Substring match against the project-relative POSIX path — that's
  925. // exactly the format both the watcher and every codegraph response
  926. // emit, so a plain includes() is sufficient and avoids regex pitfalls.
  927. if (text.includes(p.path)) inResponse.push(p);
  928. else elsewhere.push(p);
  929. }
  930. let banner = '';
  931. if (inResponse.length > 0) {
  932. banner = formatStaleBanner(inResponse);
  933. }
  934. let footer = '';
  935. if (elsewhere.length > 0) {
  936. footer = formatStaleFooter(elsewhere);
  937. }
  938. if (!banner && !footer) return result;
  939. const composed = [banner, text, footer].filter(Boolean).join('\n\n');
  940. return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
  941. }
  942. /**
  943. * Execute a tool by name
  944. */
  945. async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  946. try {
  947. // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
  948. // surface rejects ablated tools defensively even if a client cached them.
  949. if (!this.isToolAllowed(toolName)) {
  950. return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
  951. }
  952. // Cross-cutting input validation. All tools accept an optional
  953. // `projectPath` and most accept either `query`, `task`, or
  954. // `symbol` — bound their lengths centrally so individual handlers
  955. // can stay focused on tool-specific logic.
  956. const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
  957. if (typeof pathCheck === 'object' && pathCheck !== undefined) {
  958. return pathCheck;
  959. }
  960. // The `path` and `pattern` properties used by codegraph_files are
  961. // also path-shaped — apply the same cap.
  962. if (args.path !== undefined) {
  963. const check = this.validateOptionalPath(args.path, 'path');
  964. if (typeof check === 'object' && check !== undefined) return check;
  965. }
  966. if (args.pattern !== undefined) {
  967. const check = this.validateOptionalPath(args.pattern, 'pattern');
  968. if (typeof check === 'object' && check !== undefined) return check;
  969. }
  970. // Read tools resolve through a single result variable so cross-cutting
  971. // notices — worktree-index mismatch (issue #155) and per-file
  972. // staleness (issue #403) — can be applied in one place. status embeds
  973. // its own verbose worktree warning but still flows through the
  974. // staleness wrapper so its pending-files section stays consistent
  975. // with what the read tools surface.
  976. let result: ToolResult;
  977. switch (toolName) {
  978. case 'codegraph_search':
  979. result = await this.handleSearch(args); break;
  980. case 'codegraph_context':
  981. result = await this.handleContext(args); break;
  982. case 'codegraph_callers':
  983. result = await this.handleCallers(args); break;
  984. case 'codegraph_callees':
  985. result = await this.handleCallees(args); break;
  986. case 'codegraph_impact':
  987. result = await this.handleImpact(args); break;
  988. case 'codegraph_explore':
  989. result = await this.handleExplore(args); break;
  990. case 'codegraph_node':
  991. result = await this.handleNode(args); break;
  992. case 'codegraph_status':
  993. // status embeds the pending-files list as a first-class section
  994. // (see handleStatus), so we skip the auto-banner wrapper here to
  995. // avoid duplicating the same info at the top of the response.
  996. return await this.handleStatus(args);
  997. case 'codegraph_files':
  998. result = await this.handleFiles(args); break;
  999. case 'codegraph_trace':
  1000. result = await this.handleTrace(args); break;
  1001. default:
  1002. return this.errorResult(`Unknown tool: ${toolName}`);
  1003. }
  1004. const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
  1005. return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
  1006. } catch (err) {
  1007. return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
  1008. }
  1009. }
  1010. /**
  1011. * Handle codegraph_search
  1012. */
  1013. private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
  1014. const query = this.validateString(args.query, 'query');
  1015. if (typeof query !== 'string') return query;
  1016. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1017. const kind = args.kind as string | undefined;
  1018. const rawLimit = Number(args.limit) || 10;
  1019. const limit = clamp(rawLimit, 1, 100);
  1020. const results = cg.searchNodes(query, {
  1021. limit,
  1022. kinds: kind ? [kind as NodeKind] : undefined,
  1023. });
  1024. if (results.length === 0) {
  1025. return this.textResult(`No results found for "${query}"`);
  1026. }
  1027. // Down-rank generated files within the FTS-returned set so a search
  1028. // for "Send" surfaces the hand-written keeper before .pb.go stubs
  1029. // that share the name. Stable: only reorders generated vs. not.
  1030. const ranked = [...results].sort((a, b) => {
  1031. const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0;
  1032. const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0;
  1033. return aGen - bGen;
  1034. });
  1035. const formatted = this.formatSearchResults(ranked);
  1036. return this.textResult(this.truncateOutput(formatted));
  1037. }
  1038. /**
  1039. * Handle codegraph_context
  1040. */
  1041. private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
  1042. const task = this.validateString(args.task, 'task');
  1043. if (typeof task !== 'string') return task;
  1044. // Mark session as consulted (enables Grep/Glob/Bash)
  1045. const sessionId = process.env.CLAUDE_SESSION_ID;
  1046. if (sessionId) {
  1047. markSessionConsulted(sessionId);
  1048. }
  1049. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1050. // On tiny repos (<150 files), trim maxNodes hard — the entire repo
  1051. // is grep-able in a turn so a 20-node context is wasted budget.
  1052. // 8 covers the typical 1-3 entry-point + their immediate neighbors
  1053. // without dragging in the rest of the small codebase.
  1054. let defaultMaxNodes = 20;
  1055. let isTinyRepo = false;
  1056. let isSmallRepo = false;
  1057. try {
  1058. const stats = cg.getStats();
  1059. if (stats.fileCount < 150) { defaultMaxNodes = 8; isTinyRepo = true; }
  1060. else if (stats.fileCount < 500) { isSmallRepo = true; }
  1061. } catch {
  1062. // stats failure — fall back to the standard default
  1063. }
  1064. const maxNodes = (args.maxNodes as number) || defaultMaxNodes;
  1065. const includeCode = args.includeCode !== false;
  1066. const context = await cg.buildContext(task, {
  1067. maxNodes,
  1068. includeCode,
  1069. format: 'markdown',
  1070. });
  1071. // Detect if this looks like a feature request (vs bug fix or exploration)
  1072. const isFeatureQuery = this.looksLikeFeatureRequest(task);
  1073. const reminder = isFeatureQuery
  1074. ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
  1075. : '';
  1076. // Auto-trace for flow queries: when the task is asking "how does X
  1077. // reach/flow/propagate from A to B", run the trace internally and
  1078. // append its body to the context response. Saves the agent the
  1079. // follow-up codegraph_trace call that was the #2 cost driver on
  1080. // multi-module flow questions (Q3 / etcd Q2 in the audit).
  1081. const flowTrace = await this.maybeInlineFlowTrace(task, cg);
  1082. // Iter3 — sufficiency steering on small repos.
  1083. //
  1084. // Measured economics on tiny (<150) and small (<500) projects: every
  1085. // additional MCP tool call costs ~$0.02-0.05 in cache-write tokens
  1086. // (5K-15K per response at $3.75/1M). The agent reflexively follows
  1087. // codegraph_context with explore/node even when the context response
  1088. // is already sufficient — that pattern drove the cost gap that
  1089. // smaller bodies (iter2) failed to close (smaller bodies just shifted
  1090. // the agent to Read instead). Direct directive on small-repo
  1091. // responses: tell the agent the context call IS the comprehensive
  1092. // pass for a project of this size and that follow-ups should be
  1093. // narrow (trace from→to, node single-symbol) — not another broad
  1094. // explore that re-bundles the same content.
  1095. // ITER4: unified strong directive for both tiny (<150) and small
  1096. // (<500) tiers — measured iter3 result was that the soft <500
  1097. // wording was IGNORED on sinatra (5 tool calls, +92% loss) while
  1098. // the strong <150 wording was followed on cobra/slim (3 calls,
  1099. // -21%/-22% wins). The single-file-framework problem (sinatra)
  1100. // is structurally the same as cobra's; both deserve the same
  1101. // sufficiency steering.
  1102. let smallRepoTail = '';
  1103. let smallRepoRouteInline = '';
  1104. if (isTinyRepo || isSmallRepo) {
  1105. // Iter12: backend-computed routing manifest for routing queries.
  1106. // Builds a URL → handler map directly from the graph (each route
  1107. // node has a `references` edge to its handler), then inlines the
  1108. // top handler file's source. The agent gets the canonical
  1109. // routing answer in one MCP call — no need to parse framework
  1110. // DSL or grep for handlers.
  1111. //
  1112. // Replaces iter10's raw route-file inline. The manifest is more
  1113. // information-dense (parsed URL→handler map vs raw config DSL)
  1114. // and we still inline the top handler file's source so the agent
  1115. // has the implementation bodies inline too.
  1116. const isRouteQuery = /\b(route|routes|routing|request|handler|endpoint|api|controller|middleware|dispatch|invok)/i.test(task);
  1117. if (isRouteQuery) {
  1118. try {
  1119. const manifest = cg.getRoutingManifest(40);
  1120. if (manifest) {
  1121. // 1) Compact URL→handler list (~30-60 lines, ~1-2KB).
  1122. const lines: string[] = [
  1123. `\n\n## Routing manifest (${manifest.totalRoutes} routes, top handler file holds ${manifest.topHandlerFileCount})`,
  1124. '',
  1125. '| URL | Handler | Location |',
  1126. '|---|---|---|',
  1127. ];
  1128. for (const e of manifest.entries) {
  1129. lines.push(`| \`${e.url}\` | \`${e.handler}\` | ${e.handlerFile}:${e.handlerLine} |`);
  1130. }
  1131. // 2) Inline the top handler file's source.
  1132. if (manifest.topHandlerFile && manifest.topHandlerFileCount >= 2) {
  1133. try {
  1134. const fullPath = pathModule.join(cg.getProjectRoot(), manifest.topHandlerFile);
  1135. const stat = statSync(fullPath);
  1136. if (stat.size > 0 && stat.size <= 16000) {
  1137. const source = readFileSync(fullPath, 'utf-8');
  1138. const capped = source.length > 7000 ? source.slice(0, 7000) + '\n... (truncated)' : source;
  1139. const ext = (manifest.topHandlerFile.match(/\.([a-z]+)$/i)?.[1] || '').toLowerCase();
  1140. const lang =
  1141. ext === 'rb' ? 'ruby' : ext === 'py' ? 'python' :
  1142. ext === 'go' ? 'go' : ext === 'rs' ? 'rust' :
  1143. ext === 'js' || ext === 'jsx' ? 'javascript' :
  1144. ext === 'ts' || ext === 'tsx' ? 'typescript' :
  1145. ext === 'java' ? 'java' : ext === 'kt' ? 'kotlin' :
  1146. ext === 'cs' ? 'csharp' : ext === 'php' ? 'php' :
  1147. ext === 'swift' ? 'swift' : ext === 'yml' || ext === 'yaml' ? 'yaml' : '';
  1148. lines.push('');
  1149. lines.push(`### Top handler file (\`${manifest.topHandlerFile}\` — ${manifest.topHandlerFileCount}/${manifest.totalRoutes} routes, full source inlined — do NOT Read)`);
  1150. lines.push('');
  1151. lines.push('```' + lang);
  1152. lines.push(capped);
  1153. lines.push('```');
  1154. }
  1155. } catch { /* file read failed, skip the source inline */ }
  1156. }
  1157. smallRepoRouteInline = lines.join('\n');
  1158. }
  1159. } catch {
  1160. // Manifest build failed — drop silently
  1161. }
  1162. }
  1163. const sizeQualifier = isTinyRepo ? 'under 150' : 'under 500';
  1164. const routingClause = smallRepoRouteInline
  1165. ? ' The URL→handler manifest and top handler file are also inlined above — answer routing questions from them.'
  1166. : '';
  1167. smallRepoTail = `\n\n---\n> **This project is small** (${sizeQualifier} indexed files). The entry points and code above cover the relevant surface — **do NOT call codegraph_explore as a follow-up; its content will largely duplicate this response**. If you need a specific flow, call \`codegraph_trace from→to\`. If you need one specific symbol's body, call \`codegraph_node <name>\`.${routingClause} Otherwise, answer from what is above.`;
  1168. }
  1169. // buildContext returns string when format is 'markdown'
  1170. if (typeof context === 'string') {
  1171. return this.textResult(this.truncateOutput(context + flowTrace + reminder + smallRepoRouteInline + smallRepoTail));
  1172. }
  1173. // If it returns TaskContext, format it
  1174. return this.textResult(this.truncateOutput(this.formatTaskContext(context) + flowTrace + reminder + smallRepoRouteInline + smallRepoTail));
  1175. }
  1176. /**
  1177. * Detect a flow-style task ("how does X reach Y", "trace the path from A to B")
  1178. * and pre-run trace between the most likely endpoints, returning the trace
  1179. * body to splice into the context response. Returns '' for non-flow queries
  1180. * or when no plausible endpoint pair can be extracted.
  1181. *
  1182. * Conservative by design: only fires when the task has both a clear flow
  1183. * keyword AND at least two distinct PascalCase / camelCase identifiers.
  1184. * False positives waste a graph query; false negatives just fall back to
  1185. * the agent calling trace itself (existing path-proximity wiring handles
  1186. * disambiguation either way).
  1187. */
  1188. private async maybeInlineFlowTrace(task: string, cg: CodeGraph): Promise<string> {
  1189. const lower = task.toLowerCase();
  1190. const FLOW_KEYWORDS = [
  1191. 'trace ',
  1192. 'from ',
  1193. 'reach ',
  1194. 'flow ',
  1195. 'propagat',
  1196. 'how does ',
  1197. 'how do ',
  1198. ];
  1199. if (!FLOW_KEYWORDS.some((k) => lower.includes(k))) return '';
  1200. // Extract candidate symbols — PascalCase or camelCase identifiers ≥3 chars.
  1201. // Filter out common non-symbol words and the flow keywords themselves.
  1202. const STOP_WORDS = new Set([
  1203. 'how', 'does', 'the', 'and', 'from', 'through', 'reach', 'reaches',
  1204. 'flow', 'path', 'trace', 'cross', 'module', 'modules', 'where',
  1205. 'update', 'updates', 'updated', 'when', 'what', 'this', 'that',
  1206. ]);
  1207. const ids: string[] = [];
  1208. const seen = new Set<string>();
  1209. const re = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)+|[a-z]+[A-Z][a-z]*(?:[A-Z][a-z]*)*)\b/g;
  1210. let m: RegExpExecArray | null;
  1211. while ((m = re.exec(task)) !== null) {
  1212. const sym = m[1]!;
  1213. if (sym.length < 3) continue;
  1214. const key = sym.toLowerCase();
  1215. if (STOP_WORDS.has(key) || seen.has(key)) continue;
  1216. seen.add(key);
  1217. ids.push(sym);
  1218. }
  1219. if (ids.length < 2) return '';
  1220. // The first two distinct symbols, in order of appearance, are the most
  1221. // likely from/to endpoints — "from X ... through to Y" naturally places
  1222. // them in that order in the prose. If the trace fails to connect, it
  1223. // still returns the inlined endpoint bodies (the trace-failure rewrite).
  1224. const fromSym = ids[0]!;
  1225. const toSym = ids[1]!;
  1226. let traceResult: ToolResult;
  1227. try {
  1228. traceResult = await this.handleTrace({
  1229. from: fromSym,
  1230. to: toSym,
  1231. projectPath: cg.getProjectRoot(),
  1232. } as Record<string, unknown>);
  1233. } catch {
  1234. return '';
  1235. }
  1236. // Extract the textual body. Defensive: handleTrace's contract is the
  1237. // standard tool-result shape used elsewhere in this file.
  1238. const body = traceResult.content
  1239. ?.map((c) => (c.type === 'text' ? c.text : ''))
  1240. .filter(Boolean)
  1241. .join('\n')
  1242. .trim();
  1243. if (!body) return '';
  1244. return [
  1245. '',
  1246. '## Inline flow trace',
  1247. '',
  1248. `Auto-traced \`${fromSym}\` → \`${toSym}\` because the query looks like a flow question. No follow-up codegraph_trace is needed for this pair.`,
  1249. '',
  1250. body,
  1251. ].join('\n');
  1252. }
  1253. /**
  1254. * Heuristic to detect if a query looks like a feature request
  1255. */
  1256. private looksLikeFeatureRequest(task: string): boolean {
  1257. const featureKeywords = [
  1258. 'add', 'create', 'implement', 'build', 'enable', 'allow',
  1259. 'new feature', 'support for', 'ability to', 'want to',
  1260. 'should be able', 'need to add', 'swap', 'edit', 'modify'
  1261. ];
  1262. const bugKeywords = [
  1263. 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
  1264. 'not working', 'fails', 'undefined', 'null'
  1265. ];
  1266. const explorationKeywords = [
  1267. 'how does', 'where is', 'what is', 'find', 'show me',
  1268. 'explain', 'understand', 'explore'
  1269. ];
  1270. const lowerTask = task.toLowerCase();
  1271. // If it's clearly a bug or exploration, not a feature
  1272. if (bugKeywords.some(k => lowerTask.includes(k))) return false;
  1273. if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
  1274. // If it matches feature keywords, it's likely a feature request
  1275. return featureKeywords.some(k => lowerTask.includes(k));
  1276. }
  1277. /**
  1278. * Handle codegraph_callers
  1279. */
  1280. private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
  1281. const symbol = this.validateString(args.symbol, 'symbol');
  1282. if (typeof symbol !== 'string') return symbol;
  1283. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1284. const limit = clamp((args.limit as number) || 20, 1, 100);
  1285. const allMatches = this.findAllSymbols(cg, symbol);
  1286. if (allMatches.nodes.length === 0) {
  1287. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  1288. }
  1289. // Aggregate callers across all matching symbols
  1290. const seen = new Set<string>();
  1291. const allCallers: Node[] = [];
  1292. for (const node of allMatches.nodes) {
  1293. for (const c of cg.getCallers(node.id)) {
  1294. if (!seen.has(c.node.id)) {
  1295. seen.add(c.node.id);
  1296. allCallers.push(c.node);
  1297. }
  1298. }
  1299. }
  1300. if (allCallers.length === 0) {
  1301. return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
  1302. }
  1303. const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
  1304. return this.textResult(this.truncateOutput(formatted));
  1305. }
  1306. /**
  1307. * Handle codegraph_callees
  1308. */
  1309. private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
  1310. const symbol = this.validateString(args.symbol, 'symbol');
  1311. if (typeof symbol !== 'string') return symbol;
  1312. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1313. const limit = clamp((args.limit as number) || 20, 1, 100);
  1314. const allMatches = this.findAllSymbols(cg, symbol);
  1315. if (allMatches.nodes.length === 0) {
  1316. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  1317. }
  1318. // Aggregate callees across all matching symbols
  1319. const seen = new Set<string>();
  1320. const allCallees: Node[] = [];
  1321. for (const node of allMatches.nodes) {
  1322. for (const c of cg.getCallees(node.id)) {
  1323. if (!seen.has(c.node.id)) {
  1324. seen.add(c.node.id);
  1325. allCallees.push(c.node);
  1326. }
  1327. }
  1328. }
  1329. if (allCallees.length === 0) {
  1330. return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
  1331. }
  1332. const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
  1333. return this.textResult(this.truncateOutput(formatted));
  1334. }
  1335. /**
  1336. * Handle codegraph_impact
  1337. */
  1338. private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
  1339. const symbol = this.validateString(args.symbol, 'symbol');
  1340. if (typeof symbol !== 'string') return symbol;
  1341. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1342. const depth = clamp((args.depth as number) || 2, 1, 10);
  1343. const allMatches = this.findAllSymbols(cg, symbol);
  1344. if (allMatches.nodes.length === 0) {
  1345. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  1346. }
  1347. // Aggregate impact across all matching symbols
  1348. const mergedNodes = new Map<string, Node>();
  1349. const mergedEdges: Edge[] = [];
  1350. const seenEdges = new Set<string>();
  1351. for (const node of allMatches.nodes) {
  1352. const impact = cg.getImpactRadius(node.id, depth);
  1353. for (const [id, n] of impact.nodes) {
  1354. mergedNodes.set(id, n);
  1355. }
  1356. for (const e of impact.edges) {
  1357. const key = `${e.source}->${e.target}:${e.kind}`;
  1358. if (!seenEdges.has(key)) {
  1359. seenEdges.add(key);
  1360. mergedEdges.push(e);
  1361. }
  1362. }
  1363. }
  1364. const mergedImpact = {
  1365. nodes: mergedNodes,
  1366. edges: mergedEdges,
  1367. roots: allMatches.nodes.map(n => n.id),
  1368. };
  1369. const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
  1370. return this.textResult(this.truncateOutput(formatted));
  1371. }
  1372. /**
  1373. * Handle codegraph_trace — shortest CALL PATH between two symbols.
  1374. *
  1375. * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
  1376. * each hop annotated with file:line and the call-site line. This is the
  1377. * capability grep/Read structurally cannot provide. When no static path
  1378. * exists, the chain has almost certainly broken at dynamic dispatch
  1379. * (callbacks, descriptors, metaclasses) — we say so and surface the start
  1380. * symbol's outgoing calls so the agent bridges the one missing hop with
  1381. * codegraph_node rather than blindly reading.
  1382. */
  1383. private async handleTrace(args: Record<string, unknown>): Promise<ToolResult> {
  1384. const from = this.validateString(args.from, 'from');
  1385. if (typeof from !== 'string') return from;
  1386. const to = this.validateString(args.to, 'to');
  1387. if (typeof to !== 'string') return to;
  1388. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1389. const fromMatches = this.findAllSymbols(cg, from);
  1390. if (fromMatches.nodes.length === 0) return this.textResult(`Symbol "${from}" not found in the codebase`);
  1391. const toMatches = this.findAllSymbols(cg, to);
  1392. if (toMatches.nodes.length === 0) return this.textResult(`Symbol "${to}" not found in the codebase`);
  1393. // Trace along call edges only — a true call path. Names can map to several
  1394. // nodes, so try a few from×to candidate pairs until a usable path turns up.
  1395. //
  1396. // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
  1397. // is almost always a spurious wander through unrelated code (django's
  1398. // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
  1399. // the real execution flow — and a confident-but-wrong 15-hop trace is worse
  1400. // than none. Over-cap paths are rejected and reported as "no direct path"
  1401. // (which, on real code, means the flow breaks at dynamic dispatch).
  1402. const edgeKinds: Edge['kind'][] = ['calls'];
  1403. const MAX_HOPS = 7;
  1404. // Path-proximity pairing: in a multi-module repo a symbol name like
  1405. // `EndBlocker` exists in 20+ modules. FTS picks one almost arbitrarily;
  1406. // the WRONG pair (e.g. simapp's wrapper EndBlocker paired with gov's Tally)
  1407. // has no static path, falls through to the dynamic-dispatch failure branch,
  1408. // and surfaces unrelated bodies — exactly the cosmos-Q3 trace failure mode.
  1409. // Score every from×to combo by shared file-path prefix length; try the
  1410. // most-co-located pair first (e.g. `x/gov/abci.go::EndBlocker` ×
  1411. // `x/gov/keeper/tally.go::Tally` share `x/gov/`).
  1412. //
  1413. // Consider the FULL candidate set, not just the FTS top-5: the right
  1414. // EndBlocker for a gov-module flow may rank 8th in FTS but share the
  1415. // entire `x/gov/` prefix with the destination. Path-proximity supersedes
  1416. // FTS for this disambiguation. Findpath trials are still capped by
  1417. // FINDPATH_PAIR_BUDGET below to bound graph traversal cost.
  1418. const sharedDirPrefixLen = (a: string, b: string): number => {
  1419. const aDir = a.replace(/[^/]+$/, '');
  1420. const bDir = b.replace(/[^/]+$/, '');
  1421. let i = 0;
  1422. while (i < aDir.length && i < bDir.length && aDir[i] === bDir[i]) i++;
  1423. return i;
  1424. };
  1425. // Cosmos-Q3 surfaced a second-order failure: `enterprise/group/x/group/`
  1426. // SHARES MORE of its path with `enterprise/group/x/group/keeper/tally.go`
  1427. // (24 chars) than `x/gov/abci.go` shares with `x/gov/keeper/tally.go`
  1428. // (6 chars), so pure shared-prefix prefers the side-experiment module
  1429. // over the canonical one — even though the user's question is clearly
  1430. // about the main gov module. Penalize candidates living under prefixes
  1431. // that conventionally hold extensions / experiments / vendored code, so
  1432. // the canonical-path pair wins even when its shared prefix is short.
  1433. const isLessCanonicalPath = (p: string): boolean =>
  1434. /^(enterprise|contrib|examples?|sample|playground|vendor|third[_-]?party|deprecated|legacy)\//i.test(p);
  1435. const LESS_CANONICAL_PENALTY = 100; // any canonical candidate beats any less-canonical one
  1436. const scorePair = (a: string, b: string): number =>
  1437. sharedDirPrefixLen(a, b)
  1438. - (isLessCanonicalPath(a) ? LESS_CANONICAL_PENALTY : 0)
  1439. - (isLessCanonicalPath(b) ? LESS_CANONICAL_PENALTY : 0);
  1440. const fromCands = fromMatches.nodes;
  1441. const toCands = toMatches.nodes;
  1442. const pairs: Array<{ f: Node; t: Node; score: number }> = [];
  1443. for (const f of fromCands) {
  1444. for (const t of toCands) {
  1445. pairs.push({ f, t, score: scorePair(f.filePath, t.filePath) });
  1446. }
  1447. }
  1448. // Sort by shared prefix desc, then by FTS order (already encoded in the
  1449. // pairs' insertion order — both for f and t). The tiebreaker preserves
  1450. // findAllSymbols' generated-file-last ranking.
  1451. pairs.sort((a, b) => b.score - a.score);
  1452. // Cap how many graph-path probes we attempt so a 50×50 cross-product
  1453. // doesn't blow up on a god-named symbol like `Get` (well-named flows have
  1454. // their good pair near the top of the sort anyway).
  1455. const FINDPATH_PAIR_BUDGET = 20;
  1456. const fromTry = fromCands;
  1457. const toTry = toCands;
  1458. let path: Array<{ node: Node; edge: Edge | null }> | null = null;
  1459. let overCap: Array<{ node: Node; edge: Edge | null }> | null = null;
  1460. let bestPair: { f: Node; t: Node } | null = null;
  1461. let triedPairs = 0;
  1462. for (const { f, t } of pairs) {
  1463. if (path) break;
  1464. if (triedPairs >= FINDPATH_PAIR_BUDGET) break;
  1465. triedPairs++;
  1466. const p = cg.findPath(f.id, t.id, edgeKinds);
  1467. if (p && p.length > 1) {
  1468. if (p.length <= MAX_HOPS) { path = p; bestPair = { f, t }; break; }
  1469. if (!overCap || p.length < overCap.length) { overCap = p; bestPair = { f, t }; }
  1470. } else if (!bestPair) {
  1471. // No path yet — remember the top-scored pair so the failure branch
  1472. // surfaces the most-co-located candidates' bodies, not whatever FTS
  1473. // happened to put first.
  1474. bestPair = { f, t };
  1475. }
  1476. }
  1477. if (!path) {
  1478. // No static path — almost always a dynamic-dispatch break. INSTEAD of
  1479. // telling the agent to chase the gap with codegraph_node/callers/callees
  1480. // (which fans out into 3-4 follow-up tool calls + a Read), inline the
  1481. // material those would have returned right here. Measured on cosmos-Q3:
  1482. // the failed-trace + subsequent fan-out used to cost ~2× a single
  1483. // sufficient trace call; this branch closes that gap.
  1484. // Prefer the path-proximity-best pair we identified above (e.g. gov's
  1485. // EndBlocker × gov's Tally) over the FTS top-pick (simapp's wrapper).
  1486. const start = bestPair?.f ?? fromTry[0]!;
  1487. const end = bestPair?.t ?? toTry[0]!;
  1488. const fileCache = new Map<string, string[]>();
  1489. const lines = [
  1490. `No direct static call path from "${from}" to "${to}" — the chain almost certainly breaks at dynamic dispatch (a callback / interface dispatch / framework hook / metaclass). Both endpoint bodies + their immediate neighbors are inlined below; answer from them — a follow-up codegraph_node/callers/callees on these would just return what is already here.`,
  1491. '',
  1492. ];
  1493. if (overCap) {
  1494. lines.push(
  1495. `> Indirect chain of ${overCap.length} hops exists but is over the ${MAX_HOPS}-hop cap (usually a BFS wander through unrelated code, not the real execution flow).`,
  1496. '',
  1497. );
  1498. }
  1499. // Track which node IDs we've already inlined a body for so we don't
  1500. // double-emit when a callee of FROM is also surfaced separately.
  1501. const inlinedBodies = new Set<string>();
  1502. const inlineBody = (n: Node, lineCap: number, charCap: number): boolean => {
  1503. if (inlinedBodies.has(n.id)) return false;
  1504. inlinedBodies.add(n.id);
  1505. const body = this.sourceRangeAt(cg, n.filePath, n.startLine, n.endLine, fileCache, lineCap, charCap);
  1506. if (body) { lines.push(body); return true; }
  1507. return false;
  1508. };
  1509. const inlineEndpoint = (
  1510. label: 'FROM' | 'TO',
  1511. node: Node,
  1512. ) => {
  1513. lines.push(`### ${label}: \`${node.name}\` (${node.filePath}:${node.startLine}-${node.endLine})`);
  1514. inlineBody(node, 120, 3600);
  1515. const callers = cg.getCallers(node.id).slice(0, 6);
  1516. if (callers.length > 0) {
  1517. lines.push(`**Callers of \`${node.name}\`:** ` +
  1518. callers.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
  1519. }
  1520. const callees = cg.getCallees(node.id).slice(0, 8);
  1521. if (callees.length > 0) {
  1522. lines.push(`**\`${node.name}\` calls:** ` +
  1523. callees.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
  1524. }
  1525. lines.push('');
  1526. };
  1527. inlineEndpoint('FROM', start);
  1528. if (end.id !== start.id) inlineEndpoint('TO', end);
  1529. // Inline the OTHER top-level functions/methods in TO's file — that's
  1530. // where the missing dynamic-dispatch flow usually lives. Concrete
  1531. // measurement from cosmos-Q1: `msgServer.Send` statically calls only
  1532. // utility functions (`StringToBytes`, `Wrapf`); its real next-hop
  1533. // `SendCoins` is invoked via an embedded-interface call (`k.Keeper.SendCoins`)
  1534. // that static parsing CAN'T see. The flow IS in the same file as the
  1535. // destination (`x/bank/keeper/send.go`: SendCoins → subUnlockedCoins →
  1536. // addCoins → setBalance). Pre-inlining those file-mates is what
  1537. // replaces the agent's "trace fail → search SendCoins → node SendCoins
  1538. // → trace again" fan-out.
  1539. const NEIGHBOR_LINES = 40;
  1540. const NEIGHBOR_CHARS = 1200;
  1541. const NEIGHBOR_K = 5;
  1542. const fileSiblings = (anchor: Node): Node[] => {
  1543. // Functions and methods in the same file as the anchor, excluding
  1544. // the anchor itself and anything we've already inlined. Sort by
  1545. // distance from the anchor's startLine so the closest symbols come
  1546. // first (the flow is usually adjacent in the file).
  1547. const sameFile = cg
  1548. .getNodesByKind('function')
  1549. .filter((n) => n.filePath === anchor.filePath)
  1550. .concat(
  1551. cg.getNodesByKind('method').filter((n) => n.filePath === anchor.filePath),
  1552. );
  1553. return sameFile
  1554. .filter((n) => n.id !== anchor.id && !inlinedBodies.has(n.id))
  1555. .sort((a, b) =>
  1556. Math.abs(a.startLine - anchor.startLine) - Math.abs(b.startLine - anchor.startLine),
  1557. )
  1558. .slice(0, NEIGHBOR_K);
  1559. };
  1560. const renderSiblings = (label: string, siblings: Node[]) => {
  1561. if (siblings.length === 0) return;
  1562. lines.push(`### ${label}`);
  1563. for (const sib of siblings) {
  1564. lines.push('');
  1565. lines.push(`- \`${sib.name}\` (${sib.filePath}:${sib.startLine}-${sib.endLine})`);
  1566. inlineBody(sib, NEIGHBOR_LINES, NEIGHBOR_CHARS);
  1567. }
  1568. lines.push('');
  1569. };
  1570. renderSiblings(
  1571. `Other functions in \`${end.filePath}\` (the flow that the dynamic-dispatch hop reaches — bodies inlined)`,
  1572. fileSiblings(end),
  1573. );
  1574. lines.push(
  1575. '> Endpoint bodies + the other functions in the destination\'s file are inlined above. Together they typically cover the missing dynamic-dispatch boundary (interface-method calls like `k.Keeper.SendCoins` that static parsing can\'t follow). **No further codegraph_node / codegraph_callers / codegraph_callees / Read / Grep is needed for any symbol already shown here** — call them again only if you need to walk DEEPER than what is inlined.',
  1576. );
  1577. return this.textResult(this.truncateOutput(lines.join('\n') + fromMatches.note + toMatches.note));
  1578. }
  1579. const lines: string[] = [
  1580. `## Trace: ${from} → ${to}`,
  1581. '',
  1582. `Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
  1583. '',
  1584. `${path.length} hops:`,
  1585. '',
  1586. ];
  1587. // Inline what each hop needs so the agent doesn't Read/Grep to get it: the
  1588. // call-site source line, the registration site for dynamic-dispatch hops, AND
  1589. // the hop's own body (capped per hop so the trace stays path-scoped). Earlier
  1590. // versions inlined only the call-site line, which left agents calling explore
  1591. // or Read for the bodies — the exact follow-up the ablation experiment measured.
  1592. const fileCache = new Map<string, string[]>();
  1593. for (let i = 0; i < path.length; i++) {
  1594. const step = path[i]!;
  1595. if (step.edge) {
  1596. const synth = this.synthEdgeNote(step.edge);
  1597. if (synth) {
  1598. lines.push(` ↓ ${synth.label}`);
  1599. if (synth.registeredAt) {
  1600. const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
  1601. lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
  1602. }
  1603. } else {
  1604. // The call happens in the PREVIOUS hop's file at edge.line.
  1605. const prev = path[i - 1];
  1606. const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
  1607. const callSrc = this.sourceLineAt(cg, ref, fileCache);
  1608. lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
  1609. }
  1610. }
  1611. lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
  1612. const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
  1613. if (body) lines.push(body);
  1614. }
  1615. // The "last mile": what the destination does next. Agents otherwise explore/Read
  1616. // for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
  1617. // so inlining the destination's callees is what actually stops the investigation —
  1618. // sufficiency, not a "don't explore" instruction.
  1619. const dest = path[path.length - 1]!.node;
  1620. const destCallees = cg.getCallees(dest.id)
  1621. .filter(c => !path.some(p => p.node.id === c.node.id))
  1622. .slice(0, 6);
  1623. if (destCallees.length > 0) {
  1624. lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
  1625. for (const c of destCallees) {
  1626. lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
  1627. const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
  1628. if (body) lines.push(body);
  1629. }
  1630. }
  1631. 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.');
  1632. return this.textResult(this.truncateOutput(lines.join('\n')));
  1633. }
  1634. /**
  1635. * Describe a synthesized (dynamic-dispatch) edge for human output: how the
  1636. * callback was wired up — the bridge static parsing can't see. Returns null
  1637. * for ordinary static edges. Used by trace + the node trail so a synthesized
  1638. * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
  1639. */
  1640. private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
  1641. if (!edge || edge.provenance !== 'heuristic') return null;
  1642. const m = edge.metadata as Record<string, unknown> | undefined;
  1643. const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
  1644. const at = registeredAt ? ` @${registeredAt}` : '';
  1645. if (m?.synthesizedBy === 'callback') {
  1646. const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
  1647. const field = m.field ? ` on .${String(m.field)}` : '';
  1648. return {
  1649. label: `callback — registered via ${via}${field} (dynamic dispatch)`,
  1650. compact: `dynamic: callback via ${via}${at}`,
  1651. registeredAt,
  1652. };
  1653. }
  1654. if (m?.synthesizedBy === 'event-emitter') {
  1655. const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
  1656. return {
  1657. label: `event ${ev} — emit → handler (dynamic dispatch)`,
  1658. compact: `dynamic: event ${ev}${at}`,
  1659. registeredAt,
  1660. };
  1661. }
  1662. if (m?.synthesizedBy === 'react-render') {
  1663. return {
  1664. label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
  1665. compact: `dynamic: React re-render via setState${at}`,
  1666. registeredAt,
  1667. };
  1668. }
  1669. if (m?.synthesizedBy === 'jsx-render') {
  1670. const child = m.via ? `<${String(m.via)}>` : 'a child component';
  1671. return {
  1672. label: `renders ${child} (JSX child — dynamic dispatch)`,
  1673. compact: `dynamic: renders ${child}`,
  1674. registeredAt,
  1675. };
  1676. }
  1677. if (m?.synthesizedBy === 'vue-handler') {
  1678. const ev = m.event ? `@${String(m.event)}` : 'a template event';
  1679. return {
  1680. label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
  1681. compact: `dynamic: Vue ${ev} handler`,
  1682. registeredAt,
  1683. };
  1684. }
  1685. if (m?.synthesizedBy === 'interface-impl') {
  1686. return {
  1687. label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
  1688. compact: `dynamic: interface → impl${at}`,
  1689. registeredAt,
  1690. };
  1691. }
  1692. return null;
  1693. }
  1694. /**
  1695. * Read one trimmed source line at "relpath:line" (relative to the project
  1696. * root). `cache` holds split file contents so a multi-hop trace reads each
  1697. * file at most once. Returns null if the file/line can't be resolved.
  1698. */
  1699. private sourceLineAt(cg: CodeGraph, ref: string | undefined, cache: Map<string, string[]>): string | null {
  1700. if (!ref) return null;
  1701. const i = ref.lastIndexOf(':');
  1702. if (i < 0) return null;
  1703. const filePath = ref.slice(0, i);
  1704. const line = parseInt(ref.slice(i + 1), 10);
  1705. if (!Number.isFinite(line) || line < 1) return null;
  1706. let fileLines = cache.get(filePath);
  1707. if (!fileLines) {
  1708. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  1709. if (!abs || !existsSync(abs)) return null;
  1710. try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
  1711. cache.set(filePath, fileLines);
  1712. }
  1713. const raw = fileLines[line - 1];
  1714. if (raw == null) return null;
  1715. const t = raw.trim();
  1716. return t.length > 160 ? t.slice(0, 157) + '…' : t;
  1717. }
  1718. /**
  1719. * Read a hop's body — filePath lines [startLine..endLine] — for inlining into
  1720. * a trace, capped (lines + chars) so the whole path stays path-scoped even on
  1721. * a 7-hop chain. Dedents to the body's own indentation and marks truncation.
  1722. * Shares `cache` with sourceLineAt so each file is read at most once per trace.
  1723. */
  1724. private sourceRangeAt(
  1725. cg: CodeGraph,
  1726. filePath: string,
  1727. startLine: number,
  1728. endLine: number,
  1729. cache: Map<string, string[]>,
  1730. maxLines = 28,
  1731. maxChars = 1200
  1732. ): string | null {
  1733. if (!Number.isFinite(startLine) || startLine < 1) return null;
  1734. let fileLines = cache.get(filePath);
  1735. if (!fileLines) {
  1736. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  1737. if (!abs || !existsSync(abs)) return null;
  1738. try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
  1739. cache.set(filePath, fileLines);
  1740. }
  1741. const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
  1742. let slice = fileLines.slice(startLine - 1, end);
  1743. if (slice.length === 0) return null;
  1744. let omitted = 0;
  1745. if (slice.length > maxLines) { omitted = slice.length - maxLines; slice = slice.slice(0, maxLines); }
  1746. const nonBlank = slice.filter(l => l.trim().length > 0);
  1747. const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
  1748. let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
  1749. if (text.length > maxChars) {
  1750. text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
  1751. omitted = Math.max(omitted, 1);
  1752. }
  1753. if (omitted > 0) text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
  1754. return text;
  1755. }
  1756. /**
  1757. * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
  1758. * symbol names that usually spans the flow it's investigating (e.g.
  1759. * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
  1760. * Surface the longest call chain AMONG those named symbols — scoped to what the
  1761. * agent explicitly named, so (unlike a fuzzy relevance set) there's no
  1762. * wrong-feature wandering. Rides synthesized edges, so controller→service-
  1763. * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
  1764. *
  1765. * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
  1766. * CO-NAMING: the agent names the class too, so we keep only `list` candidates
  1767. * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
  1768. * dropping unrelated `OmsOrderService::list`.
  1769. */
  1770. private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): string {
  1771. try {
  1772. const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
  1773. // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
  1774. // names (Class.method / Class::method) — the agent's most precise input,
  1775. // resolved exactly by findAllSymbols. (The old strip mangled Class.method
  1776. // into Class, throwing the method away.)
  1777. 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;
  1778. const tokens = [...new Set(
  1779. query.split(/[\s,()[\]]+/)
  1780. .map((t) => t.replace(FILE_EXT, '').trim())
  1781. .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
  1782. )].slice(0, 16);
  1783. if (tokens.length < 2) return '';
  1784. // Pool of name SEGMENTS (Class + method from every token) used to
  1785. // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
  1786. // CONTAINER class is itself named in the query.
  1787. const segPool = new Set<string>();
  1788. for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
  1789. const named = new Map<string, Node>();
  1790. for (const t of tokens) {
  1791. const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
  1792. // A qualified or otherwise-specific name (<=3 hits) keeps all; an
  1793. // ambiguous simple name keeps only candidates whose container is named.
  1794. const pick = cands.length <= 3
  1795. ? cands
  1796. : cands.filter((n) => {
  1797. const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
  1798. const container = segs.length >= 2 ? segs[segs.length - 2] : '';
  1799. return !!container && segPool.has(container);
  1800. });
  1801. for (const n of pick.slice(0, 6)) named.set(n.id, n);
  1802. if (named.size > 40) break;
  1803. }
  1804. if (named.size < 2) return '';
  1805. const MAX_HOPS = 7;
  1806. let best: Array<{ node: Node; edge: Edge | null }> | null = null;
  1807. // BFS the full call graph (incl. synth edges) from each named seed, but
  1808. // only ACCEPT a sink that is also named — both ends anchored to symbols the
  1809. // agent named, so the chain stays on-topic while bridging intermediates
  1810. // (e.g. the exact interface overload) that the token resolution missed.
  1811. for (const seed of [...named.values()].slice(0, 8)) {
  1812. const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
  1813. parent.set(seed.id, { prev: null, edge: null, node: seed });
  1814. const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
  1815. let deep: string | null = null, deepDepth = 0;
  1816. const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
  1817. for (let h = 0; h < q.length && parent.size < 1500; h++) {
  1818. const { id, depth, streak } = q[h]!;
  1819. if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
  1820. if (depth >= MAX_HOPS - 1) continue;
  1821. for (const c of cg.getCallees(id)) {
  1822. if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
  1823. const newStreak = named.has(c.node.id) ? 0 : streak + 1;
  1824. if (newStreak > MAX_BRIDGE) continue;
  1825. parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
  1826. q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
  1827. }
  1828. }
  1829. if (!deep) continue;
  1830. const chain: Array<{ node: Node; edge: Edge | null }> = [];
  1831. let cur: string | null = deep;
  1832. while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
  1833. chain.reverse();
  1834. if (!best || chain.length > best.length) best = chain;
  1835. }
  1836. if (!best || best.length < 3) return '';
  1837. const out = ['## Flow (call path among the symbols you queried)', ''];
  1838. for (let i = 0; i < best.length; i++) {
  1839. const step = best[i]!;
  1840. if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`); }
  1841. out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
  1842. }
  1843. out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
  1844. return out.join('\n');
  1845. } catch {
  1846. return '';
  1847. }
  1848. }
  1849. /**
  1850. * Handle codegraph_explore — deep exploration in a single call
  1851. *
  1852. * Strategy: find relevant symbols via graph traversal, group by file,
  1853. * then read contiguous file sections covering all symbols per file.
  1854. * This replaces multiple codegraph_node + Read calls.
  1855. *
  1856. * Output size is adaptive to project file count via
  1857. * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
  1858. * tax on small projects while earning its keep on large ones.
  1859. */
  1860. private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
  1861. const query = this.validateString(args.query, 'query');
  1862. if (typeof query !== 'string') return query;
  1863. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1864. const projectRoot = cg.getProjectRoot();
  1865. // Resolve adaptive output budget from project size. Falls back to the
  1866. // largest-tier defaults if stats aren't available, which preserves
  1867. // pre-#185 behavior for callers that hit the rare stats failure.
  1868. let budget: ExploreOutputBudget;
  1869. try {
  1870. budget = getExploreOutputBudget(cg.getStats().fileCount);
  1871. } catch {
  1872. budget = getExploreOutputBudget(Infinity);
  1873. }
  1874. const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
  1875. // Step 1: Find relevant context with generous parameters.
  1876. // Use a large maxNodes budget — explore has its own 35k char output limit
  1877. // that prevents context bloat, so more nodes just means better coverage
  1878. // across entry points (especially for large files like Svelte components).
  1879. const subgraph = await cg.findRelevantContext(query, {
  1880. searchLimit: 8,
  1881. traversalDepth: 3,
  1882. maxNodes: 200,
  1883. minScore: 0.2,
  1884. });
  1885. if (subgraph.nodes.size === 0) {
  1886. return this.textResult(`No relevant code found for "${query}"`);
  1887. }
  1888. // Graph-aware glue: findRelevantContext builds the subgraph from name/text
  1889. // search, so a method that BRIDGES named symbols — e.g. App.tsx's
  1890. // triggerRender, which calls the named triggerUpdate — is never a search hit
  1891. // and gets missed, forcing the agent to Read the file to trace it. Pull in
  1892. // the callers/callees of the entry (root) nodes, but ONLY those that live in
  1893. // files the subgraph already surfaces (where the agent reads to fill gaps),
  1894. // so we add wiring without dragging in unrelated files. These get an
  1895. // importance boost below so they survive the per-file cluster budget.
  1896. const glueNodeIds = new Set<string>();
  1897. const subgraphFiles = new Set<string>();
  1898. for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
  1899. const GLUE_NODE_CAP = 60;
  1900. for (const rootId of subgraph.roots) {
  1901. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1902. let neighbors: Node[] = [];
  1903. try {
  1904. neighbors = [
  1905. ...cg.getCallers(rootId).map(c => c.node),
  1906. ...cg.getCallees(rootId).map(c => c.node),
  1907. ];
  1908. } catch {
  1909. continue;
  1910. }
  1911. for (const nb of neighbors) {
  1912. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1913. if (subgraph.nodes.has(nb.id)) continue;
  1914. if (!subgraphFiles.has(nb.filePath)) continue;
  1915. subgraph.nodes.set(nb.id, nb);
  1916. glueNodeIds.add(nb.id);
  1917. }
  1918. }
  1919. // Step 2: Group nodes by file, score by relevance
  1920. const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
  1921. const entryNodeIds = new Set(subgraph.roots);
  1922. // Build a set of nodes directly connected to entry points (depth 1)
  1923. const connectedToEntry = new Set<string>();
  1924. for (const edge of subgraph.edges) {
  1925. if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
  1926. if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
  1927. }
  1928. for (const node of subgraph.nodes.values()) {
  1929. // Skip import/export nodes — they add noise without information
  1930. if (node.kind === 'import' || node.kind === 'export') continue;
  1931. const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
  1932. group.nodes.push(node);
  1933. // Score: entry point nodes worth 10, directly connected worth 3, others worth 1
  1934. if (entryNodeIds.has(node.id)) {
  1935. group.score += 10;
  1936. } else if (connectedToEntry.has(node.id)) {
  1937. group.score += 3;
  1938. } else {
  1939. group.score += 1;
  1940. }
  1941. fileGroups.set(node.filePath, group);
  1942. }
  1943. // Only include files that have entry points or nodes directly connected to entry points
  1944. let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
  1945. // Extract query terms for relevance checking
  1946. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  1947. // Test/spec/icon/i18n file detector — used both for the pre-sort hard
  1948. // filter (tiny tier) and the comparator deprioritization (all tiers).
  1949. const isLowValue = (p: string) => {
  1950. const lp = p.toLowerCase();
  1951. return (
  1952. /\/(tests?|__tests?__|spec)\//.test(lp) ||
  1953. /_test\.go$/.test(lp) ||
  1954. /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
  1955. /_test\.py$/.test(lp) ||
  1956. /_spec\.rb$/.test(lp) ||
  1957. /_test\.rb$/.test(lp) ||
  1958. /\.(test|spec)\.[jt]sx?$/.test(lp) ||
  1959. /(test|spec|tests)\.(java|kt|scala)$/.test(lp) ||
  1960. /(tests?|spec)\.cs$/.test(lp) ||
  1961. /tests?\.swift$/.test(lp) ||
  1962. /_test\.dart$/.test(lp) ||
  1963. /\bicons?\b/.test(lp) ||
  1964. /\bi18n\b/.test(lp)
  1965. );
  1966. };
  1967. // Tiny-tier hard-exclude: on small projects (`excludeLowValueFiles`
  1968. // budget flag), one slipped test/spec file dominates the per-file budget
  1969. // (cobra's `command_test.go` displaced `args.go` and contributed ~10KB of
  1970. // pure noise to "How does cobra parse commands?"). The sort-step
  1971. // deprioritization isn't enough at small N. Skip the hard-exclude when
  1972. // the query itself is about tests — that's the legitimate "explore the
  1973. // tests" case where the agent does want them.
  1974. if (budget.excludeLowValueFiles) {
  1975. const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(query);
  1976. if (!queryMentionsTests) {
  1977. const nonLow = relevantFiles.filter(([p]) => !isLowValue(p));
  1978. // Only apply the hard-filter if we still have at least 2 non-test
  1979. // candidates after the cut — otherwise the agent is asking about an
  1980. // area where tests are the only signal, and we should not strip them.
  1981. if (nonLow.length >= 2) {
  1982. relevantFiles = nonLow;
  1983. }
  1984. }
  1985. }
  1986. // Sort files: highest relevance first, deprioritize low-value files
  1987. const sortedFiles = relevantFiles.sort((a, b) => {
  1988. const aPath = a[0].toLowerCase();
  1989. const bPath = b[0].toLowerCase();
  1990. // Check if any node name or file path relates to query terms
  1991. const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
  1992. const fp = filePath.toLowerCase();
  1993. if (queryTerms.some(t => fp.includes(t))) return true;
  1994. return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
  1995. };
  1996. const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
  1997. const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
  1998. if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
  1999. const aLow = isLowValue(aPath);
  2000. const bLow = isLowValue(bPath);
  2001. if (aLow !== bLow) return aLow ? 1 : -1;
  2002. // Deprioritize generated source (.pb.go / .pulsar.go / _mocks.go / …) —
  2003. // the agent rarely needs to see the protobuf scaffold or gomock output
  2004. // when asking about the actual flow, and dumping their bodies inflates
  2005. // the response (the cosmos Q3 explore otherwise leads with
  2006. // `expected_keepers_mocks.go`, displacing the real `tally.go` content
  2007. // and forcing the agent to Read tally.go anyway).
  2008. const aGen = isGeneratedFile(a[0]);
  2009. const bGen = isGeneratedFile(b[0]);
  2010. if (aGen !== bGen) return aGen ? 1 : -1;
  2011. if (a[1].score !== b[1].score) return b[1].score - a[1].score;
  2012. return b[1].nodes.length - a[1].nodes.length;
  2013. });
  2014. // Step 3: Build relationship map
  2015. const lines: string[] = [
  2016. `## Exploration: ${query}`,
  2017. '',
  2018. `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
  2019. '',
  2020. ];
  2021. // Relationship map — show how symbols connect
  2022. const significantEdges = subgraph.edges.filter(e =>
  2023. e.kind !== 'contains' // skip contains — it's implied by file grouping
  2024. );
  2025. if (budget.includeRelationships && significantEdges.length > 0) {
  2026. lines.push('### Relationships');
  2027. lines.push('');
  2028. // Group edges by kind for readability
  2029. const byKind = new Map<string, Array<{ source: string; target: string }>>();
  2030. for (const edge of significantEdges) {
  2031. const sourceNode = subgraph.nodes.get(edge.source);
  2032. const targetNode = subgraph.nodes.get(edge.target);
  2033. if (!sourceNode || !targetNode) continue;
  2034. const group = byKind.get(edge.kind) || [];
  2035. group.push({ source: sourceNode.name, target: targetNode.name });
  2036. byKind.set(edge.kind, group);
  2037. }
  2038. for (const [kind, edges] of byKind) {
  2039. const cap = budget.maxEdgesPerRelationshipKind;
  2040. const shown = edges.slice(0, cap);
  2041. lines.push(`**${kind}:**`);
  2042. for (const e of shown) {
  2043. lines.push(`- ${e.source} → ${e.target}`);
  2044. }
  2045. if (edges.length > cap) {
  2046. lines.push(`- ... and ${edges.length - cap} more`);
  2047. }
  2048. lines.push('');
  2049. }
  2050. }
  2051. // Step 4: Read contiguous file sections
  2052. lines.push('### Source Code');
  2053. lines.push('');
  2054. 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.');
  2055. lines.push('');
  2056. let totalChars = lines.join('\n').length;
  2057. let filesIncluded = 0;
  2058. let anyFileTrimmed = false;
  2059. for (const [filePath, group] of sortedFiles) {
  2060. if (filesIncluded >= maxFiles) break;
  2061. if (totalChars > budget.maxOutputChars * 0.9) break;
  2062. const absPath = validatePathWithinRoot(projectRoot, filePath);
  2063. if (!absPath || !existsSync(absPath)) continue;
  2064. let fileContent: string;
  2065. try {
  2066. fileContent = readFileSync(absPath, 'utf-8');
  2067. } catch {
  2068. continue;
  2069. }
  2070. const fileLines = fileContent.split('\n');
  2071. const lang = group.nodes[0]?.language || '';
  2072. // Whole-small-file rule: if a relevant file is small enough to afford,
  2073. // return it ENTIRELY instead of clustering. Clustering exists to tame
  2074. // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
  2075. // lossy subset of a file the agent will just Read in full anyway — costing
  2076. // a round-trip and a re-read every later turn. Reserve clustering for files
  2077. // too big to ship whole. Still bounded by the total maxOutputChars check.
  2078. const WHOLE_FILE_MAX_LINES = 220;
  2079. const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
  2080. if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
  2081. const body = fileContent.replace(/\n+$/, '');
  2082. let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
  2083. const uniqSymbols = [...new Set(
  2084. group.nodes
  2085. .filter(n => n.kind !== 'import' && n.kind !== 'export')
  2086. .map(n => `${n.name}(${n.kind})`)
  2087. )];
  2088. const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
  2089. const omitted = uniqSymbols.length - headerNames.length;
  2090. const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
  2091. if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
  2092. const remaining = budget.maxOutputChars - totalChars - 200;
  2093. if (remaining < 500) break;
  2094. wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
  2095. anyFileTrimmed = true;
  2096. }
  2097. lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
  2098. totalChars += wholeSection.length + 200;
  2099. filesIncluded++;
  2100. continue;
  2101. }
  2102. // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
  2103. // Sort by start line, then merge overlapping/adjacent ranges (within the
  2104. // adaptive gap threshold). Include both node ranges AND edge source
  2105. // locations so template sections with component usages/calls are
  2106. // covered (not just script block symbols).
  2107. //
  2108. // Each range carries an `importance` score so we can rank clusters
  2109. // when the per-file budget forces us to drop some: entry-point nodes
  2110. // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
  2111. // bare edge-source lines 2 (less than a connected node but more than
  2112. // a peripheral one — they hint at a reference but aren't a definition).
  2113. // Container kinds whose body can span most/all of a file. When such a
  2114. // node covers most of the file we drop it from the ranges: keeping it
  2115. // would merge every method inside it into one giant cluster spanning
  2116. // the whole file, which then tail-trims down to just the container's
  2117. // opening lines (its header/declarations) and buries the methods the
  2118. // query actually asked about (#185 follow-up — Session.swift in
  2119. // Alamofire is the canonical case: the `Session` class spans ~1,400
  2120. // lines). We want the granular symbols inside, not the envelope.
  2121. const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
  2122. const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
  2123. .filter(n => n.startLine > 0 && n.endLine > 0)
  2124. // Drop whole-file envelope nodes (containers covering >50% of the file).
  2125. .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
  2126. .map(n => {
  2127. let importance = 1;
  2128. if (entryNodeIds.has(n.id)) importance = 10;
  2129. else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
  2130. else if (connectedToEntry.has(n.id)) importance = 3;
  2131. return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
  2132. });
  2133. // Add edge source locations in this file — captures template references
  2134. // (component usages, event handlers) that aren't nodes themselves.
  2135. // Query edges directly from the DB (not just the subgraph) because BFS
  2136. // traversal may have pruned template reference targets due to node budget.
  2137. const edgeLines = new Set<string>(); // dedup by "line:name"
  2138. for (const node of group.nodes) {
  2139. const outgoing = cg.getOutgoingEdges(node.id);
  2140. for (const edge of outgoing) {
  2141. if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
  2142. const key = `${edge.line}:${edge.target}`;
  2143. if (edgeLines.has(key)) continue;
  2144. edgeLines.add(key);
  2145. // Look up target name from subgraph first, fall back to edge kind
  2146. const targetNode = subgraph.nodes.get(edge.target);
  2147. const targetName = targetNode?.name ?? edge.kind;
  2148. ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
  2149. }
  2150. }
  2151. ranges.sort((a, b) => a.start - b.start);
  2152. if (ranges.length === 0) continue;
  2153. const gapThreshold = budget.gapThreshold;
  2154. const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
  2155. let current = {
  2156. start: ranges[0]!.start,
  2157. end: ranges[0]!.end,
  2158. symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
  2159. score: ranges[0]!.importance,
  2160. maxImportance: ranges[0]!.importance,
  2161. };
  2162. for (let i = 1; i < ranges.length; i++) {
  2163. const r = ranges[i]!;
  2164. if (r.start <= current.end + gapThreshold) {
  2165. current.end = Math.max(current.end, r.end);
  2166. current.symbols.push(`${r.name}(${r.kind})`);
  2167. current.score += r.importance;
  2168. current.maxImportance = Math.max(current.maxImportance, r.importance);
  2169. } else {
  2170. clusters.push(current);
  2171. current = {
  2172. start: r.start,
  2173. end: r.end,
  2174. symbols: [`${r.name}(${r.kind})`],
  2175. score: r.importance,
  2176. maxImportance: r.importance,
  2177. };
  2178. }
  2179. }
  2180. clusters.push(current);
  2181. // Build file section output from clusters, capped by per-file budget.
  2182. // The pathological case (#185): a file like Session.swift where every
  2183. // method is adjacent collapses into one cluster spanning the whole
  2184. // file, and dumping that into the agent's context is most of the
  2185. // token cost on small projects. We pick clusters in priority order
  2186. // until the per-file char cap is hit. Truly enormous single clusters
  2187. // get tail-trimmed with a marker.
  2188. const contextPadding = 3;
  2189. const withLineNumbers = exploreLineNumbersEnabled();
  2190. const buildSection = (c: { start: number; end: number }): string => {
  2191. const startIdx = Math.max(0, c.start - 1 - contextPadding);
  2192. const endIdx = Math.min(fileLines.length, c.end + contextPadding);
  2193. const slice = fileLines.slice(startIdx, endIdx).join('\n');
  2194. // startIdx is 0-based, so the slice's first line is line startIdx + 1.
  2195. return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
  2196. };
  2197. // Language-neutral separator (no `//` — not a comment in Python, Ruby,
  2198. // etc.). With line numbers on, the line-number jump also signals the gap.
  2199. const GAP_MARKER = '\n\n... (gap) ...\n\n';
  2200. // Rank clusters for inclusion under the per-file cap. Entry-point
  2201. // clusters come first: a cluster containing a query entry point
  2202. // (importance 10) must outrank a dense block of mere declarations,
  2203. // otherwise on a large file like Session.swift the top-of-file class
  2204. // header + property list (many adjacent low-importance nodes, high
  2205. // density) wins the budget and buries the actual methods the query
  2206. // asked about (perform/didCreateURLRequest/task live deep in the
  2207. // file). Within the same importance tier, prefer density (score per
  2208. // line) so we still favor focused clusters over sprawling ones, then
  2209. // smaller span as a cheap-to-include tiebreak.
  2210. const rankedClusters = clusters
  2211. .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
  2212. .sort((a, b) => {
  2213. if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
  2214. const densityA = a.c.score / a.span;
  2215. const densityB = b.c.score / b.span;
  2216. if (densityB !== densityA) return densityB - densityA;
  2217. if (b.c.score !== a.c.score) return b.c.score - a.c.score;
  2218. return a.span - b.span;
  2219. });
  2220. const chosenIndices = new Set<number>();
  2221. let projectedChars = 0;
  2222. for (const rc of rankedClusters) {
  2223. const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
  2224. // Always take the top-ranked cluster, even if oversize, so we don't
  2225. // return an empty file section (agent would then re-Read the file,
  2226. // negating the savings).
  2227. if (chosenIndices.size === 0) {
  2228. chosenIndices.add(rc.idx);
  2229. projectedChars += sectionLen;
  2230. continue;
  2231. }
  2232. if (projectedChars + sectionLen > budget.maxCharsPerFile) continue;
  2233. chosenIndices.add(rc.idx);
  2234. projectedChars += sectionLen;
  2235. }
  2236. // Emit chosen clusters in source order so the file reads top-to-bottom.
  2237. let fileSection = '';
  2238. const allSymbols: string[] = [];
  2239. let fileTrimmed = false;
  2240. for (let i = 0; i < clusters.length; i++) {
  2241. if (!chosenIndices.has(i)) continue;
  2242. const cluster = clusters[i]!;
  2243. const section = buildSection(cluster);
  2244. if (fileSection.length > 0) fileSection += GAP_MARKER;
  2245. fileSection += section;
  2246. allSymbols.push(...cluster.symbols);
  2247. }
  2248. // If a single chosen cluster is still oversize (long monolithic
  2249. // function), tail-trim it. Better one trimmed view than nothing.
  2250. if (fileSection.length > budget.maxCharsPerFile) {
  2251. fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
  2252. fileTrimmed = true;
  2253. }
  2254. if (chosenIndices.size < clusters.length || fileTrimmed) {
  2255. anyFileTrimmed = true;
  2256. }
  2257. // Dedupe + cap the symbols list shown in the per-file header. Some
  2258. // files (Session.swift in Alamofire) produced 3.4KB symbol lists
  2259. // from cluster scoring + edge-source lines, dwarfing the per-file
  2260. // body cap. Show top names by frequency, with a "+N more" tail.
  2261. const symbolCounts = new Map<string, number>();
  2262. for (const s of allSymbols) {
  2263. symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
  2264. }
  2265. const sortedSymbols = [...symbolCounts.entries()]
  2266. .sort((a, b) => b[1] - a[1])
  2267. .map(([name]) => name);
  2268. const headerCap = budget.maxSymbolsInFileHeader;
  2269. const headerSymbols = sortedSymbols.slice(0, headerCap);
  2270. const omittedCount = sortedSymbols.length - headerSymbols.length;
  2271. const headerSuffix = omittedCount > 0
  2272. ? `${headerSymbols.join(', ')}, +${omittedCount} more`
  2273. : headerSymbols.join(', ');
  2274. const fileHeader = `#### ${filePath} — ${headerSuffix}`;
  2275. // Respect the total output cap on a file-by-file basis.
  2276. if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
  2277. const remaining = budget.maxOutputChars - totalChars - 200;
  2278. if (remaining < 500) break;
  2279. const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
  2280. lines.push(fileHeader);
  2281. lines.push('');
  2282. lines.push('```' + lang);
  2283. lines.push(trimmed);
  2284. lines.push('```');
  2285. lines.push('');
  2286. totalChars += trimmed.length + 200;
  2287. filesIncluded++;
  2288. anyFileTrimmed = true;
  2289. break;
  2290. }
  2291. lines.push(fileHeader);
  2292. lines.push('');
  2293. lines.push('```' + lang);
  2294. lines.push(fileSection);
  2295. lines.push('```');
  2296. lines.push('');
  2297. totalChars += fileSection.length + 200;
  2298. filesIncluded++;
  2299. }
  2300. // Add remaining files as references (from both relevant and peripheral files).
  2301. // Small projects (per budget) skip this — the relevant story already fits
  2302. // in the source section, and a trailing pointer list is pure overhead.
  2303. if (budget.includeAdditionalFiles) {
  2304. const remainingRelevant = sortedFiles.slice(filesIncluded);
  2305. const peripheralFiles = [...fileGroups.entries()]
  2306. .filter(([, group]) => group.score < 3)
  2307. .sort((a, b) => b[1].score - a[1].score);
  2308. const remainingFiles = [...remainingRelevant, ...peripheralFiles];
  2309. if (remainingFiles.length > 0) {
  2310. lines.push('### Not shown above — explore these names for their source');
  2311. lines.push('');
  2312. for (const [filePath, group] of remainingFiles.slice(0, 10)) {
  2313. const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  2314. lines.push(`- ${filePath}: ${symbols}`);
  2315. }
  2316. if (remainingFiles.length > 10) {
  2317. lines.push(`- ... and ${remainingFiles.length - 10} more files`);
  2318. }
  2319. }
  2320. }
  2321. // Add completeness signal so agents know they don't need to re-read these files.
  2322. // On small projects the budget gates this off — but if we actually had to
  2323. // trim or drop clusters, surface a brief note so the agent knows it can
  2324. // still Read for more detail.
  2325. if (budget.includeCompletenessSignal) {
  2326. lines.push('');
  2327. lines.push('---');
  2328. 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.`);
  2329. } else if (anyFileTrimmed) {
  2330. lines.push('');
  2331. 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.`);
  2332. }
  2333. // Add explore budget note based on project size
  2334. if (budget.includeBudgetNote) {
  2335. try {
  2336. const stats = cg.getStats();
  2337. const callBudget = getExploreBudget(stats.fileCount);
  2338. lines.push('');
  2339. 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}.`);
  2340. } catch {
  2341. // Stats unavailable — skip budget note
  2342. }
  2343. }
  2344. // Hard-cap to the adaptive budget. The per-file loop bounds the source
  2345. // sections, but the relationship map, additional-files list, and
  2346. // completeness/budget notes can still push the assembled output past
  2347. // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
  2348. // payload persists in the agent's context and is re-read as cache-input
  2349. // on every subsequent turn, so the overrun is paid many times over.
  2350. const output = this.buildFlowFromNamedSymbols(cg, query) + lines.join('\n');
  2351. if (output.length > budget.maxOutputChars) {
  2352. const cut = output.slice(0, budget.maxOutputChars);
  2353. const lastNewline = cut.lastIndexOf('\n');
  2354. const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
  2355. 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.)');
  2356. }
  2357. return this.textResult(output);
  2358. }
  2359. /**
  2360. * Handle codegraph_node
  2361. */
  2362. private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
  2363. const symbol = this.validateString(args.symbol, 'symbol');
  2364. if (typeof symbol !== 'string') return symbol;
  2365. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  2366. // Default to false to minimize context usage
  2367. const includeCode = args.includeCode === true;
  2368. const match = this.findSymbol(cg, symbol);
  2369. if (!match) {
  2370. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  2371. }
  2372. let code: string | null = null;
  2373. let outline: string | null = null;
  2374. if (includeCode) {
  2375. // For container symbols (class/interface/struct/…), the full body is the
  2376. // sum of every method body — a wall of source (e.g. a 10k-char class)
  2377. // that bloats context and is rarely needed in full. Return a structural
  2378. // outline (members + signatures + line numbers) instead; the agent can
  2379. // Read or codegraph_node a specific method for its body. Leaf symbols
  2380. // (function/method/etc.) return their full body as before.
  2381. if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
  2382. outline = this.buildContainerOutline(cg, match.node);
  2383. }
  2384. if (!outline) {
  2385. code = await cg.getCode(match.node.id);
  2386. }
  2387. }
  2388. const trail = this.formatTrail(cg, match.node);
  2389. const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
  2390. return this.textResult(this.truncateOutput(formatted));
  2391. }
  2392. /**
  2393. * Build the "trail" for a symbol: its direct callees (what it calls) and
  2394. * callers (what calls it), each with file:line — so codegraph_node doubles as
  2395. * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
  2396. * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
  2397. * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
  2398. * dynamic dispatch the static graph couldn't resolve — that absence is itself
  2399. * a signal (read that one hop) rather than a dead end.
  2400. */
  2401. private formatTrail(cg: CodeGraph, node: Node): string {
  2402. const TRAIL_CAP = 12;
  2403. const fmt = (e: { node: Node; edge: Edge }) => {
  2404. const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
  2405. const synth = this.synthEdgeNote(e.edge);
  2406. return synth ? `${base} [${synth.compact}]` : base;
  2407. };
  2408. const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
  2409. const seen = new Set<string>([node.id]);
  2410. const out: Array<{ node: Node; edge: Edge }> = [];
  2411. for (const e of edges) {
  2412. if (seen.has(e.node.id)) continue;
  2413. seen.add(e.node.id);
  2414. out.push(e);
  2415. }
  2416. return out;
  2417. };
  2418. const callees = collect(cg.getCallees(node.id));
  2419. const callers = collect(cg.getCallers(node.id));
  2420. if (callees.length === 0 && callers.length === 0) return '';
  2421. const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
  2422. if (callees.length > 0) {
  2423. lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
  2424. }
  2425. if (callers.length > 0) {
  2426. lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
  2427. }
  2428. return lines.join('\n');
  2429. }
  2430. /**
  2431. * Handle codegraph_status
  2432. */
  2433. private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
  2434. let cg = this.getCodeGraph(args.projectPath as string | undefined);
  2435. // Same trick as withStalenessNotice — when an explicit projectPath
  2436. // resolves to the same project as the default session cg, prefer the
  2437. // default so getPendingFiles() (only populated by the default's watcher)
  2438. // is non-empty when there are pending edits.
  2439. if (this.cg && cg !== this.cg) {
  2440. try {
  2441. if (resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot())) {
  2442. cg = this.cg;
  2443. }
  2444. } catch { /* closed instance — leave as is */ }
  2445. }
  2446. const stats = cg.getStats();
  2447. // Warn when this index actually belongs to a different git working tree
  2448. // (e.g. the server resolved up from a nested worktree to the main checkout).
  2449. // Queries then reflect that tree's branch, not the worktree being edited.
  2450. // status shows the verbose, multi-line form; the read tools get the compact
  2451. // one-liner via withWorktreeNotice. Both share the cached detection.
  2452. const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined);
  2453. const lines: string[] = [
  2454. '## CodeGraph Status',
  2455. '',
  2456. ];
  2457. if (mismatch) {
  2458. lines.push(`> ⚠ ${worktreeMismatchWarning(mismatch).replace(/\n/g, '\n> ')}`, '');
  2459. }
  2460. lines.push(
  2461. `**Files indexed:** ${stats.fileCount}`,
  2462. `**Total nodes:** ${stats.nodeCount}`,
  2463. `**Total edges:** ${stats.edgeCount}`,
  2464. `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
  2465. );
  2466. // Surface the active SQLite backend (node:sqlite, Node's built-in real
  2467. // SQLite — full WAL + FTS5, no native build).
  2468. lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
  2469. // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
  2470. // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
  2471. // everywhere, so a non-wal mode means the filesystem can't (network/
  2472. // virtualized mounts, WSL2 /mnt). See issue #238.
  2473. const journalMode = cg.getJournalMode();
  2474. if (journalMode === 'wal') {
  2475. lines.push(`**Journal mode:** wal (concurrent reads safe)`);
  2476. } else {
  2477. lines.push(
  2478. `**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
  2479. `can block on a concurrent write (WAL appears unsupported on this filesystem)`
  2480. );
  2481. }
  2482. lines.push('', '### Nodes by Kind:');
  2483. for (const [kind, count] of Object.entries(stats.nodesByKind)) {
  2484. if ((count as number) > 0) {
  2485. lines.push(`- ${kind}: ${count}`);
  2486. }
  2487. }
  2488. lines.push('', '### Languages:');
  2489. for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
  2490. if ((count as number) > 0) {
  2491. lines.push(`- ${lang}: ${count}`);
  2492. }
  2493. }
  2494. // Per-file freshness — the inverse of the auto-prepended staleness banner
  2495. // (issue #403). Surfacing it inside `status` gives the agent a single
  2496. // place to ask "is the index caught up?" rather than inferring from
  2497. // banners on other tool calls.
  2498. const pending = cg.getPendingFiles();
  2499. if (pending.length > 0) {
  2500. lines.push('', '### Pending sync:');
  2501. const now = Date.now();
  2502. for (const p of pending) {
  2503. const ageMs = Math.max(0, now - p.lastSeenMs);
  2504. const label = p.indexing ? 'indexing in progress' : 'pending sync';
  2505. lines.push(`- ${p.path} (edited ${ageMs}ms ago, ${label})`);
  2506. }
  2507. }
  2508. return this.textResult(lines.join('\n'));
  2509. }
  2510. /**
  2511. * Handle codegraph_files - get project file structure from the index
  2512. */
  2513. private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
  2514. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  2515. const pathFilter = args.path as string | undefined;
  2516. const pattern = args.pattern as string | undefined;
  2517. const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
  2518. const includeMetadata = args.includeMetadata !== false;
  2519. const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
  2520. // Get all files from the index
  2521. const allFiles = cg.getFiles();
  2522. if (allFiles.length === 0) {
  2523. return this.textResult('No files indexed. Run `codegraph index` first.');
  2524. }
  2525. // Filter by path prefix. Stored paths are project-relative POSIX (e.g.
  2526. // "src/foo.ts"), but agents commonly pass project-root variants like "/",
  2527. // ".", "./", "" or Windows-style "src\foo" — and prefixes with leading
  2528. // "/", "./" or "\". Normalize all of those before matching so the agent
  2529. // gets results instead of falling back to Read/Glob (see #426).
  2530. const normalizedFilter = pathFilter
  2531. ? pathFilter
  2532. .replace(/\\/g, '/')
  2533. .replace(/^(?:\.?\/+)+/, '')
  2534. .replace(/^\.$/, '')
  2535. .replace(/\/+$/, '')
  2536. : '';
  2537. let files = normalizedFilter
  2538. ? allFiles.filter(f => f.path === normalizedFilter || f.path.startsWith(normalizedFilter + '/'))
  2539. : allFiles;
  2540. // Filter by glob pattern
  2541. if (pattern) {
  2542. const regex = this.globToRegex(pattern);
  2543. files = files.filter(f => regex.test(f.path));
  2544. }
  2545. if (files.length === 0) {
  2546. return this.textResult(`No files found matching the criteria.`);
  2547. }
  2548. // Format output
  2549. let output: string;
  2550. switch (format) {
  2551. case 'flat':
  2552. output = this.formatFilesFlat(files, includeMetadata);
  2553. break;
  2554. case 'grouped':
  2555. output = this.formatFilesGrouped(files, includeMetadata);
  2556. break;
  2557. case 'tree':
  2558. default:
  2559. output = this.formatFilesTree(files, includeMetadata, maxDepth);
  2560. break;
  2561. }
  2562. return this.textResult(this.truncateOutput(output));
  2563. }
  2564. /**
  2565. * Convert glob pattern to regex
  2566. */
  2567. private globToRegex(pattern: string): RegExp {
  2568. const escaped = pattern
  2569. .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
  2570. .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
  2571. .replace(/\*/g, '[^/]*') // * matches anything except /
  2572. .replace(/\?/g, '[^/]') // ? matches single char except /
  2573. .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
  2574. return new RegExp(escaped);
  2575. }
  2576. /**
  2577. * Format files as a flat list
  2578. */
  2579. private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  2580. const lines: string[] = [`## Files (${files.length})`, ''];
  2581. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  2582. if (includeMetadata) {
  2583. lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
  2584. } else {
  2585. lines.push(`- ${file.path}`);
  2586. }
  2587. }
  2588. return lines.join('\n');
  2589. }
  2590. /**
  2591. * Format files grouped by language
  2592. */
  2593. private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  2594. const byLang = new Map<string, typeof files>();
  2595. for (const file of files) {
  2596. const existing = byLang.get(file.language) || [];
  2597. existing.push(file);
  2598. byLang.set(file.language, existing);
  2599. }
  2600. const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
  2601. // Sort languages by file count (descending)
  2602. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  2603. for (const [lang, langFiles] of sortedLangs) {
  2604. lines.push(`### ${lang} (${langFiles.length})`);
  2605. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  2606. if (includeMetadata) {
  2607. lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
  2608. } else {
  2609. lines.push(`- ${file.path}`);
  2610. }
  2611. }
  2612. lines.push('');
  2613. }
  2614. return lines.join('\n');
  2615. }
  2616. /**
  2617. * Format files as a tree structure
  2618. */
  2619. private formatFilesTree(
  2620. files: { path: string; language: string; nodeCount: number }[],
  2621. includeMetadata: boolean,
  2622. maxDepth?: number
  2623. ): string {
  2624. // Build tree structure
  2625. interface TreeNode {
  2626. name: string;
  2627. children: Map<string, TreeNode>;
  2628. file?: { language: string; nodeCount: number };
  2629. }
  2630. const root: TreeNode = { name: '', children: new Map() };
  2631. for (const file of files) {
  2632. const parts = file.path.split('/');
  2633. let current = root;
  2634. for (let i = 0; i < parts.length; i++) {
  2635. const part = parts[i];
  2636. if (!part) continue;
  2637. if (!current.children.has(part)) {
  2638. current.children.set(part, { name: part, children: new Map() });
  2639. }
  2640. current = current.children.get(part)!;
  2641. // If this is the last part, it's a file
  2642. if (i === parts.length - 1) {
  2643. current.file = { language: file.language, nodeCount: file.nodeCount };
  2644. }
  2645. }
  2646. }
  2647. // Render tree
  2648. const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
  2649. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  2650. if (maxDepth !== undefined && depth > maxDepth) return;
  2651. const connector = isLast ? '└── ' : '├── ';
  2652. const childPrefix = isLast ? ' ' : '│ ';
  2653. if (node.name) {
  2654. let line = prefix + connector + node.name;
  2655. if (node.file && includeMetadata) {
  2656. line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
  2657. }
  2658. lines.push(line);
  2659. }
  2660. const children = [...node.children.values()];
  2661. // Sort: directories first, then files, both alphabetically
  2662. children.sort((a, b) => {
  2663. const aIsDir = a.children.size > 0 && !a.file;
  2664. const bIsDir = b.children.size > 0 && !b.file;
  2665. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  2666. return a.name.localeCompare(b.name);
  2667. });
  2668. for (let i = 0; i < children.length; i++) {
  2669. const child = children[i]!;
  2670. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  2671. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  2672. }
  2673. };
  2674. renderNode(root, '', true, 0);
  2675. return lines.join('\n');
  2676. }
  2677. // =========================================================================
  2678. // Symbol resolution helpers
  2679. // =========================================================================
  2680. /**
  2681. * Find a symbol by name, handling disambiguation when multiple matches exist.
  2682. * Returns the best match and a note about alternatives if any.
  2683. */
  2684. /**
  2685. * Check if a node matches a symbol query.
  2686. *
  2687. * Accepts simple names (`run`) and three flavors of qualifier:
  2688. * - dotted `Session.request` (TS/JS/Python)
  2689. * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
  2690. * - slash `configurator/stage_apply` (path-ish)
  2691. *
  2692. * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
  2693. * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
  2694. * the canonical `crate::module::symbol` form resolves.
  2695. *
  2696. * Resolution order, last part must always equal `node.name`:
  2697. * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
  2698. * where the extractor builds the qualified name from the AST stack)
  2699. * 2. File-path containment (handles file-derived modules in Rust/
  2700. * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
  2701. */
  2702. private matchesSymbol(node: Node, symbol: string): boolean {
  2703. // Simple name match
  2704. if (node.name === symbol) return true;
  2705. // File basename match (e.g., "product-card" matches "product-card.liquid")
  2706. if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
  2707. // Qualified-name lookups: split on any supported separator. `\w` keeps
  2708. // identifier chars (incl. `_`) intact; everything else is treated as
  2709. // a separator we tolerate.
  2710. if (!/[.\/]|::/.test(symbol)) return false;
  2711. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  2712. if (parts.length < 2) return false;
  2713. const lastPart = parts[parts.length - 1]!;
  2714. if (node.name !== lastPart) return false;
  2715. // Stage 1: qualified-name suffix match. The extractor joins the
  2716. // semantic hierarchy with `::`, so `Session.request` and
  2717. // `Session::request` both become `Session::request` here.
  2718. const colonSuffix = parts.join('::');
  2719. if (node.qualifiedName.includes(colonSuffix)) return true;
  2720. // Stage 2: file-path containment. Rust modules and Python packages
  2721. // are not in `qualifiedName` — they're encoded in the file path. So
  2722. // `stage_apply::run` matches a `run` in any file whose path
  2723. // contains a `stage_apply` segment (with or without an extension).
  2724. //
  2725. // Filter out Rust path prefixes that have no file-system equivalent.
  2726. const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
  2727. if (containerHints.length === 0) return false;
  2728. const segments = node.filePath.split('/').filter((s) => s.length > 0);
  2729. return containerHints.every((hint) =>
  2730. segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
  2731. );
  2732. }
  2733. private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
  2734. // Use higher limit for qualified lookups (e.g., "Session.request",
  2735. // "stage_apply::run") since the target may rank lower in FTS when
  2736. // there are many partial matches across the qualifier parts.
  2737. const isQualified = /[.\/]|::/.test(symbol);
  2738. const limit = isQualified ? 50 : 10;
  2739. let results = cg.searchNodes(symbol, { limit });
  2740. // FTS strips colons as a special char, so `stage_apply::run` searches
  2741. // for the literal `stage_applyrun` and finds nothing. Re-search by
  2742. // the bare last part and let `matchesSymbol` filter by qualifier.
  2743. if (isQualified && results.length === 0) {
  2744. const tail = lastQualifierPart(symbol);
  2745. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
  2746. }
  2747. if (results.length === 0 || !results[0]) {
  2748. return null;
  2749. }
  2750. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  2751. if (exactMatches.length === 1) {
  2752. return { node: exactMatches[0]!.node, note: '' };
  2753. }
  2754. if (exactMatches.length > 1) {
  2755. // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …)
  2756. // so a query like "Send" prefers the keeper implementation over
  2757. // the protobuf-generated interface stub. Stable sort preserves
  2758. // FTS order within each group. See generated-detection.ts.
  2759. const ranked = [...exactMatches].sort((a, b) => {
  2760. const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0;
  2761. const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0;
  2762. return aGen - bGen;
  2763. });
  2764. // Multiple exact matches - pick first, note the others
  2765. const picked = ranked[0]!.node;
  2766. const others = ranked.slice(1).map(r =>
  2767. `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
  2768. );
  2769. const note = `\n\n> **Note:** ${ranked.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
  2770. return { node: picked, note };
  2771. }
  2772. // No exact match. For qualified lookups, don't silently fall back
  2773. // to a fuzzy result — the user typed a specific qualifier, and
  2774. // resolving `stage_apply::nonexistent_fn` to the unrelated
  2775. // `stage_apply.rs` file would be actively misleading (#173).
  2776. if (isQualified) return null;
  2777. return { node: results[0]!.node, note: '' };
  2778. }
  2779. /**
  2780. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  2781. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  2782. */
  2783. private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  2784. let results = cg.searchNodes(symbol, { limit: 50 });
  2785. // Mirror the fallback in `findSymbol` for qualified queries — FTS
  2786. // strips colons, so a module-qualified lookup needs a second pass
  2787. // by the bare last part.
  2788. if (results.length === 0 && /[.\/]|::/.test(symbol)) {
  2789. const tail = lastQualifierPart(symbol);
  2790. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
  2791. }
  2792. if (results.length === 0) {
  2793. return { nodes: [], note: '' };
  2794. }
  2795. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  2796. if (exactMatches.length <= 1) {
  2797. const node = exactMatches[0]?.node ?? results[0]!.node;
  2798. return { nodes: [node], note: '' };
  2799. }
  2800. // Same generated-file down-rank as findSymbol — keeps callers/callees
  2801. // /impact aggregation aligned (a query against "Send" returns the
  2802. // hand-written implementations before the protobuf scaffold).
  2803. const ranked = [...exactMatches].sort((a, b) => {
  2804. const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0;
  2805. const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0;
  2806. return aGen - bGen;
  2807. });
  2808. const locations = ranked.map(r =>
  2809. `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
  2810. );
  2811. const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
  2812. return { nodes: ranked.map(r => r.node), note };
  2813. }
  2814. /**
  2815. * Truncate output if it exceeds the maximum length
  2816. */
  2817. private truncateOutput(text: string): string {
  2818. if (text.length <= MAX_OUTPUT_LENGTH) return text;
  2819. const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
  2820. const lastNewline = truncated.lastIndexOf('\n');
  2821. const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
  2822. return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
  2823. }
  2824. // =========================================================================
  2825. // Formatting helpers (compact by default to reduce context usage)
  2826. // =========================================================================
  2827. private formatSearchResults(results: SearchResult[]): string {
  2828. const lines: string[] = [`## Search Results (${results.length} found)`, ''];
  2829. for (const result of results) {
  2830. const { node } = result;
  2831. const location = node.startLine ? `:${node.startLine}` : '';
  2832. // Compact format: one line per result with key info
  2833. lines.push(`### ${node.name} (${node.kind})`);
  2834. lines.push(`${node.filePath}${location}`);
  2835. if (node.signature) lines.push(`\`${node.signature}\``);
  2836. lines.push('');
  2837. }
  2838. return lines.join('\n');
  2839. }
  2840. private formatNodeList(nodes: Node[], title: string): string {
  2841. const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
  2842. for (const node of nodes) {
  2843. const location = node.startLine ? `:${node.startLine}` : '';
  2844. // Compact: just name, kind, location
  2845. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
  2846. }
  2847. return lines.join('\n');
  2848. }
  2849. private formatImpact(symbol: string, impact: Subgraph): string {
  2850. const nodeCount = impact.nodes.size;
  2851. // Compact format: just list affected symbols grouped by file
  2852. const lines: string[] = [
  2853. `## Impact: "${symbol}" affects ${nodeCount} symbols`,
  2854. '',
  2855. ];
  2856. // Group by file
  2857. const byFile = new Map<string, Node[]>();
  2858. for (const node of impact.nodes.values()) {
  2859. const existing = byFile.get(node.filePath) || [];
  2860. existing.push(node);
  2861. byFile.set(node.filePath, existing);
  2862. }
  2863. for (const [file, nodes] of byFile) {
  2864. lines.push(`**${file}:**`);
  2865. // Compact: inline list
  2866. const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  2867. lines.push(nodeList);
  2868. lines.push('');
  2869. }
  2870. return lines.join('\n');
  2871. }
  2872. /**
  2873. * Build a compact structural outline of a container symbol from its
  2874. * indexed children (methods, fields, properties, …) — name, kind,
  2875. * line number, and signature — so the agent gets the shape of a class
  2876. * without the full source of every method. Returns '' when the container
  2877. * has no indexed children, so the caller can fall back to full source.
  2878. */
  2879. private buildContainerOutline(cg: CodeGraph, node: Node): string {
  2880. const children = cg.getChildren(node.id)
  2881. .filter(c => c.kind !== 'import' && c.kind !== 'export')
  2882. .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
  2883. if (children.length === 0) return '';
  2884. const lines = [`**Members (${children.length}):**`, ''];
  2885. for (const c of children) {
  2886. const loc = c.startLine ? `:${c.startLine}` : '';
  2887. const sig = c.signature ? ` — \`${c.signature}\`` : '';
  2888. lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
  2889. }
  2890. return lines.join('\n');
  2891. }
  2892. private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
  2893. const location = node.startLine ? `:${node.startLine}` : '';
  2894. const lines: string[] = [
  2895. `## ${node.name} (${node.kind})`,
  2896. '',
  2897. `**Location:** ${node.filePath}${location}`,
  2898. ];
  2899. if (node.signature) {
  2900. lines.push(`**Signature:** \`${node.signature}\``);
  2901. }
  2902. // Only include docstring if it's short and useful
  2903. if (node.docstring && node.docstring.length < 200) {
  2904. lines.push('', node.docstring);
  2905. }
  2906. if (outline) {
  2907. lines.push('', outline, '',
  2908. `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
  2909. } else if (code) {
  2910. // Line-numbered (cat -n style, like codegraph_explore and Read) so the
  2911. // agent can cite/edit exact lines without re-Reading the file for them.
  2912. const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
  2913. lines.push('', '```' + node.language, numbered, '```');
  2914. }
  2915. return lines.join('\n');
  2916. }
  2917. private formatTaskContext(context: TaskContext): string {
  2918. return context.summary || 'No context found';
  2919. }
  2920. private textResult(text: string): ToolResult {
  2921. return {
  2922. content: [{ type: 'text', text }],
  2923. };
  2924. }
  2925. private errorResult(message: string): ToolResult {
  2926. return {
  2927. content: [{ type: 'text', text: `Error: ${message}` }],
  2928. isError: true,
  2929. };
  2930. }
  2931. }