tools.ts 348 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037
  1. /**
  2. * MCP Tool Definitions
  3. *
  4. * Defines the tools exposed by the CodeGraph MCP server.
  5. */
  6. import type CodeGraph from '../index';
  7. import type { QueryPool } from './query-pool';
  8. import { findNearestCodeGraphRoot } from '../directory';
  9. // Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same
  10. // helper in engine.ts. ToolHandler must load to answer tools/list (static
  11. // schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
  12. // CodeGraph is pulled in only when a tool actually opens a project. require() is
  13. // sync + cached (CommonJS build).
  14. const loadCodeGraph = (): typeof import('../index').default =>
  15. loadCodeGraphForTests ?? (require('../index') as typeof import('../index')).default;
  16. // Test seam (same pattern as the watcher's `__setFsWatchForTests`): vitest's
  17. // module transform can't service the lazy `require('../index')` above, so
  18. // in-process tests that exercise a genuine cross-project open (an explicit
  19. // `projectPath` to a different project — issue #1474's repro shape) inject the
  20. // already-imported class here. Never set outside tests.
  21. let loadCodeGraphForTests: typeof import('../index').default | null = null;
  22. export function __setLoadCodeGraphForTests(cls: typeof import('../index').default | null): void {
  23. loadCodeGraphForTests = cls;
  24. }
  25. import {
  26. detectWorktreeIndexMismatch,
  27. worktreeMismatchWarning,
  28. worktreeMismatchNotice,
  29. type WorktreeIndexMismatch,
  30. } from '../sync/worktree';
  31. import type { PendingFile } from '../sync';
  32. import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
  33. import { isTestFile, normalizeNameToken } from '../search/query-utils';
  34. import { extractQueryPaths, queryMightContainPaths } from '../search/query-paths';
  35. import {
  36. existsSync,
  37. readFileSync,
  38. statSync,
  39. } from 'fs';
  40. import { createHash } from 'crypto';
  41. import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
  42. import { scanDynamicDispatch } from './dynamic-boundaries';
  43. import { getUpdateNotice } from '../upgrade/update-check';
  44. import { ExploreDiagnostics } from './explore-diagnostics';
  45. import {
  46. EXPLORE_EMISSION_KEY,
  47. EXPLORE_SESSION_VIEW_ARG,
  48. ExploreSessionState,
  49. readExploreSessionView,
  50. viewForProject,
  51. type ExploreEmission,
  52. type ExploreFileEmission,
  53. type ExploreLineRange,
  54. } from './explore-session-state';
  55. import {
  56. EXPLORE_DEDUP,
  57. dedupeRange,
  58. exploreDedupEnabled,
  59. fileFingerprint,
  60. formatBackReference,
  61. mergeRanges,
  62. servedRangesForFile,
  63. symbolsInSpans,
  64. } from './explore-dedup';
  65. /**
  66. * An expected, recoverable "codegraph can't serve this" condition — most
  67. * importantly a project with no index. The dispatch catch converts these to
  68. * SUCCESS-shaped responses (guidance text, NO isError): an `isError: true`
  69. * early in a session teaches the agent the toolset is broken and it stops
  70. * calling codegraph entirely (observed repeatedly), which is exactly wrong
  71. * for conditions the agent can simply work around (use built-in tools for
  72. * that codebase / pass projectPath). isError is reserved for "stop trying"
  73. * cases: security refusals ({@link PathRefusalError}) and genuine
  74. * malfunctions.
  75. */
  76. export class NotIndexedError extends Error {}
  77. /**
  78. * A security refusal (sensitive system path). Stays `isError: true` WITHOUT
  79. * retry guidance — abandoning this path is the desired agent reaction.
  80. */
  81. export class PathRefusalError extends Error {}
  82. import { resolve as resolvePath, relative as relativePath } from 'path';
  83. /** Maximum output length to prevent context bloat (characters) */
  84. const MAX_OUTPUT_LENGTH = 15000;
  85. /**
  86. * Maximum length for free-form string inputs (query, task, symbol).
  87. * Bounds memory and CPU when a buggy or hostile MCP client sends a
  88. * huge payload — without this an attacker could ship a 100MB string
  89. * and force a full FTS5 scan / OOM the server. 10 000 characters is
  90. * far beyond any realistic legitimate query.
  91. */
  92. const MAX_INPUT_LENGTH = 10_000;
  93. /**
  94. * Maximum length for path-like string inputs (projectPath, path
  95. * filter, glob pattern). Paths beyond a few thousand chars are
  96. * never legitimate and signal abuse or a bug upstream.
  97. */
  98. const MAX_PATH_LENGTH = 4_096;
  99. /**
  100. * Rust path roots that have no file-system equivalent — `crate` is the
  101. * current crate, `super` is the parent module, `self` is the current
  102. * module. Used by `matchesSymbol` to strip these before file-path
  103. * matching so `crate::configurator::stage_apply::run` resolves the
  104. * same as `configurator::stage_apply::run`.
  105. */
  106. const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
  107. /**
  108. * Node kinds that contain other symbols. For these, `codegraph_node` with
  109. * `includeCode=true` returns a structural outline (member names + signatures
  110. * + line numbers) instead of the full body, which for a large class is a
  111. * multi-thousand-character wall of source that bloats the agent's context.
  112. */
  113. const CONTAINER_NODE_KINDS = new Set<NodeKind>([
  114. 'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
  115. ]);
  116. /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
  117. function lastQualifierPart(symbol: string): string {
  118. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  119. return parts[parts.length - 1] ?? symbol;
  120. }
  121. /**
  122. * Normalize Erlang-native symbol spellings in an explore query into the shapes
  123. * the rest of the pipeline already understands. Agents working Erlang code
  124. * name symbols the way the language spells them — `mod:fn/3`, `init/2` — and
  125. * those tokens previously died in both consumers: the flow-builder's token
  126. * filter rejects `:` and `/arity` outright, and the search-side field parser
  127. * eats `mod:fn` as an unknown `field:value`. Measured on cowboy: the agent
  128. * named `cowboy_stream_h:request_process/3` in two queries, got no body back
  129. * either time, and fell back to Read.
  130. *
  131. * - `fn/3` → `fn` (arity tail after an identifier; a path segment like
  132. * `src/2fa` doesn't match because the tail must be all digits)
  133. * - `mod:fn` → `mod.fn` (exactly one colon between identifiers, so it rides
  134. * the existing Class.method qualified handling; `::`, URLs, drive letters,
  135. * and times don't match, and the query language's own field prefixes —
  136. * kind:/lang:/language:/path:/name: — are left alone)
  137. *
  138. * Safe cross-language: Lua's `t:m` spelling maps to the same `t.m` its
  139. * qualified names use, and no other supported spelling contains a bare
  140. * single-colon identifier pair.
  141. */
  142. export function normalizeQuerySpelling(query: string): string {
  143. return query
  144. .replace(/\b([A-Za-z_][\w@]*)\/(\d{1,3})(?=$|[\s,()[\]/])/g, '$1')
  145. .replace(
  146. /(^|[\s,()[\]])(?!(?:kind|lang|language|path|name):)([a-z_][\w@]*):([A-Za-z_][\w@]*)(?=$|[\s,()[\]])/g,
  147. '$1$2.$3'
  148. );
  149. }
  150. /**
  151. * Calculate the recommended number of codegraph_explore calls based on project size.
  152. * Larger codebases need more exploration calls to cover their surface area,
  153. * but smaller ones should use fewer to avoid unnecessary overhead.
  154. */
  155. export function getExploreBudget(fileCount: number): number {
  156. if (fileCount < 500) return 1;
  157. if (fileCount < 5000) return 2;
  158. if (fileCount < 15000) return 3;
  159. if (fileCount < 25000) return 4;
  160. return 5;
  161. }
  162. /**
  163. * Adaptive output budget for `codegraph_explore`, scaled to project size.
  164. *
  165. * Smaller codebases get a tighter total cap, fewer default files, smaller
  166. * per-file cap, and tighter clustering — so a focused query on a 100-file
  167. * project doesn't dump a whole file's worth of source into the agent's
  168. * context. Larger codebases keep the generous defaults because the
  169. * agent's native discovery cost (grep + find + many Reads) genuinely
  170. * dwarfs a fat explore call at that scale.
  171. *
  172. * Meta-text (relationships map, "additional relevant files" list,
  173. * completeness signal, budget note) is gated off for tiny projects
  174. * where one rich call is the whole story and the extra prose is just
  175. * overhead.
  176. *
  177. * Tier breakpoints mirror `getExploreBudget` so a project sits in the
  178. * same tier across both knobs.
  179. */
  180. export interface ExploreOutputBudget {
  181. /** Hard cap on total output characters. */
  182. maxOutputChars: number;
  183. /** Default `maxFiles` when the caller didn't specify one. */
  184. defaultMaxFiles: number;
  185. /** Cap on contiguous source returned per file (across all its clusters). */
  186. maxCharsPerFile: number;
  187. /** Cluster gap threshold in lines — tighter clustering on small projects. */
  188. gapThreshold: number;
  189. /** Max symbols listed in the per-file header (``**`path`** — sym(kind), ...``). */
  190. maxSymbolsInFileHeader: number;
  191. /** Max edges shown per relationship kind in the Relationships section. */
  192. maxEdgesPerRelationshipKind: number;
  193. /** Include the "Relationships" section. */
  194. includeRelationships: boolean;
  195. /** Include the "Additional relevant files (not shown)" trailing list. */
  196. includeAdditionalFiles: boolean;
  197. /** Include the "Complete source code is included above…" reminder. */
  198. includeCompletenessSignal: boolean;
  199. /** Include the explore-budget reminder at the end. */
  200. includeBudgetNote: boolean;
  201. }
  202. export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
  203. // Tiered budget, scaled to project size. The budget is a CEILING (relevance
  204. // still gates WHAT is included), and it MUST stay under the agent's INLINE
  205. // tool-result cap (~25K chars). Above that, the host externalizes the result
  206. // to a file the agent then Reads back — re-introducing a read AND the
  207. // cache-write cost — which is exactly what a 35K vscode explore did in the
  208. // n=4 README A/B. So even large repos cap at ~24K: the answer is the handful
  209. // of ~100-line flow windows the agent would have grep-located and read (it
  210. // natively reads ~6–9 files, median 100-line ranges), NOT a sprawl of 12
  211. // files. Concentration onto the flow emerges from this cap + the named-file-
  212. // first sort dropping peripheral files. Invariant: a larger tier must never
  213. // get a smaller `maxCharsPerFile` than a smaller tier.
  214. if (fileCount < 150) {
  215. return {
  216. // ITER3: revert iter2's aggressive body shrink (forced Read fallback —
  217. // the per-file 2.5K cap pushed the agent to Read instead of node).
  218. // Back to the iter1 shape (13K/4/3.8K) but keep the test-file
  219. // hard-exclude. The cost lever for this tier lives in steering the
  220. // agent to stop after 1-2 calls, not in this budget.
  221. maxOutputChars: 13000,
  222. defaultMaxFiles: 4,
  223. maxCharsPerFile: 3800,
  224. gapThreshold: 7,
  225. maxSymbolsInFileHeader: 5,
  226. maxEdgesPerRelationshipKind: 4,
  227. includeRelationships: false,
  228. includeAdditionalFiles: false,
  229. includeCompletenessSignal: false,
  230. includeBudgetNote: false,
  231. };
  232. }
  233. if (fileCount < 500) {
  234. return {
  235. // ITER3: same revert/keep-filter pattern as <150.
  236. maxOutputChars: 18000,
  237. defaultMaxFiles: 5,
  238. maxCharsPerFile: 3800,
  239. gapThreshold: 8,
  240. maxSymbolsInFileHeader: 6,
  241. maxEdgesPerRelationshipKind: 6,
  242. includeRelationships: false,
  243. includeAdditionalFiles: false,
  244. includeCompletenessSignal: false,
  245. includeBudgetNote: false,
  246. };
  247. }
  248. if (fileCount < 5000) {
  249. return {
  250. // ~150-line per-file window (the native read unit) × ~6 files, capped at
  251. // the ~24K inline ceiling so the response is never externalized. Per-file
  252. // stays ≥ the <500 tier (3800) — monotonic.
  253. maxOutputChars: 24000,
  254. defaultMaxFiles: 8,
  255. maxCharsPerFile: 6500,
  256. gapThreshold: 12,
  257. maxSymbolsInFileHeader: 10,
  258. maxEdgesPerRelationshipKind: 10,
  259. includeRelationships: true,
  260. includeAdditionalFiles: true,
  261. includeCompletenessSignal: true,
  262. includeBudgetNote: true,
  263. };
  264. }
  265. // Large + very-large repos: SAME ~24K inline ceiling (a bigger response just
  266. // externalizes — see vscode). More files indexed → more CALLS via
  267. // getExploreBudget, not a bigger single response. Per-file 7000 (≥ smaller
  268. // tiers) gives the central file a ~180-line orientation window.
  269. if (fileCount < 15000) {
  270. return {
  271. maxOutputChars: 24000,
  272. defaultMaxFiles: 8,
  273. maxCharsPerFile: 7000,
  274. gapThreshold: 15,
  275. maxSymbolsInFileHeader: 15,
  276. maxEdgesPerRelationshipKind: 15,
  277. includeRelationships: true,
  278. includeAdditionalFiles: true,
  279. includeCompletenessSignal: true,
  280. includeBudgetNote: true,
  281. };
  282. }
  283. return {
  284. maxOutputChars: 24000,
  285. defaultMaxFiles: 8,
  286. maxCharsPerFile: 7000,
  287. gapThreshold: 15,
  288. maxSymbolsInFileHeader: 15,
  289. maxEdgesPerRelationshipKind: 15,
  290. includeRelationships: true,
  291. includeAdditionalFiles: true,
  292. includeCompletenessSignal: true,
  293. includeBudgetNote: true,
  294. };
  295. }
  296. // ── Explore relevance scoring (CG-10 / #1500) ──────────────────────────────
  297. //
  298. // A file earns its slice of the explore envelope from the symbols in it that the
  299. // query matched. Before this weighting every match counted the same per tier, so
  300. // a file that merely declares a local `const explore` scored what a file that
  301. // DEFINES the explore pipeline scored — which is how three
  302. // `scripts/agent-eval/*.mjs` harnesses took 63% of this repo's own "how does
  303. // explore allocate its output budget across files" response on nothing but a
  304. // local `explore` and a `BUDGET` constant. Four levers — the first three are
  305. // multiplicative, so they compose without ordering surprises; the fourth decides
  306. // admission from the result:
  307. //
  308. // 1. KIND — what a match on this NodeKind actually tells you (below).
  309. // 2. ISOLATION — a weak-kind symbol nothing calls or references is a pure name
  310. // collision; participation in the graph is the corroboration.
  311. // 3. PENALTY — generated / test / i18n files are weaker answers to an
  312. // architecture question at EVERY signal, not just as the
  313. // tiebreak-at-equal-score they used to be.
  314. // 4. FLOOR — admission scales with the best file's score, replacing an
  315. // absolute bar that admitted noise wherever the top score was
  316. // high.
  317. /**
  318. * How strongly a match on a symbol of this kind corroborates that its FILE is
  319. * what the query is about.
  320. *
  321. * 1.0 a callable or a type — the unit an architecture question is about
  322. * ~0.5 a member of a type, or the file node itself (a path match, not a
  323. * symbol match)
  324. * ~0.3 a variable / constant — as often a name collision as a definition
  325. * 0.15 a parameter — essentially never the subject of a question
  326. *
  327. * Unlisted kinds fall back to `DEFAULT_RELEVANCE_KIND_WEIGHT`, so a NodeKind
  328. * added later is neither free nor fatal.
  329. */
  330. export const RELEVANCE_KIND_WEIGHT: Readonly<Record<string, number>> = {
  331. // Callables and types: the answer lives in one of these.
  332. function: 1, method: 1, class: 1, struct: 1, union: 1, interface: 1, trait: 1,
  333. protocol: 1, component: 1, route: 1, enum: 1, type_alias: 1, constructor: 1,
  334. // Containers: real structure, but a whole namespace/module matching a term is
  335. // a coarser signal than a callable matching it.
  336. namespace: 0.8, module: 0.8,
  337. // Members of a type: real, weaker on their own.
  338. property: 0.5, field: 0.5, enum_member: 0.35,
  339. // The file node itself — the path matched, no symbol did.
  340. file: 0.5,
  341. // Incidental until the graph corroborates them (see ISOLATED_ below).
  342. constant: 0.35, variable: 0.3, parameter: 0.15,
  343. };
  344. const DEFAULT_RELEVANCE_KIND_WEIGHT = 0.5;
  345. /**
  346. * Kinds whose evidentiary value depends on whether anything USES them. An
  347. * exported `const DEFAULTS` that half the codebase references is a real
  348. * definition; a `const explore` living inside one function of an eval script is
  349. * a name collision. Only these kinds pay for the isolation probe.
  350. */
  351. const WEAK_RELEVANCE_KINDS: ReadonlySet<string> = new Set([
  352. 'constant', 'variable', 'parameter', 'field', 'property', 'enum_member',
  353. ]);
  354. /** Weight for a weak-kind symbol with no incoming/outgoing usage edge at all. */
  355. const ISOLATED_WEAK_KIND_WEIGHT = 0.08;
  356. /**
  357. * Edges that mean "this symbol is used". `contains` is lexical nesting, not
  358. * usage — counting it would make every file-scope constant look corroborated,
  359. * which is exactly the case this guards against.
  360. */
  361. const RELEVANCE_USAGE_EDGES: ReadonlySet<string> = new Set([
  362. 'calls', 'references', 'extends', 'implements', 'overrides',
  363. 'instantiates', 'returns', 'type_of', 'decorates',
  364. ]);
  365. /**
  366. * Cap on what PERIPHERAL nodes (in the subgraph, but neither a query match nor
  367. * adjacent to one) can contribute to a file's score. Uncapped, each such node
  368. * added a flat +1, so a file grew more "relevant" simply by being bigger —
  369. * `parse-session.mjs` reached score 22 off ONE incidental constant plus twelve
  370. * unrelated symbols. Size is not evidence; cap its contribution.
  371. */
  372. const PERIPHERAL_SCORE_CAP = 5;
  373. /**
  374. * Rank penalties, applied to BOTH the relevance score and the graph mass.
  375. *
  376. * Generated source used to be a tiebreak at equal score only, so a generated
  377. * file that outscored the hand-written one still won — the #1500 report exactly:
  378. * the FKIT CRUD layer carries every query term AND more graph mass than the
  379. * use-case that implements the business rule. A multiplier demotes it on the
  380. * PRIMARY sort key instead, without ever hard-excluding it (ask about the
  381. * generated API by name and the named-seed tier still puts it first). It is
  382. * self-normalizing: in an all-generated repo everything scales together and
  383. * relative ranking is untouched.
  384. */
  385. const GENERATED_RANK_PENALTY = 0.3;
  386. /**
  387. * Test/spec/icon/i18n files. These are normally hard-excluded outright, but that
  388. * filter stands down when fewer than 2 non-low-value candidates remain (else
  389. * tests would be the only signal for the area). This is the softened form for
  390. * that case: down-weighted rather than removed.
  391. */
  392. const LOW_VALUE_RANK_PENALTY = 0.5;
  393. /**
  394. * Ambient declaration files — a hand-written `.d.ts` of global shims, vendored
  395. * typings, module augmentation (CG-28). Declares nothing but types, and nothing
  396. * in the index depends on it.
  397. *
  398. * Such a file cannot answer a FLOW question no matter how much its identifiers
  399. * overlap the query: no bodies, no call edges, no behaviour, and nothing typed
  400. * by it. Its ceiling of usefulness is a type signature, and one follow-up
  401. * explore fetches that. But the identifiers it declares are exactly the generic
  402. * ones a prose question uses (`Body`, `Message`, `ImageMetadata`,
  403. * `ReadableStream`), so on term overlap it out-scores the implementation and
  404. * takes the envelope — measured at rank #1 and 51% of delivered source, with
  405. * the flow's own entry file getting none.
  406. *
  407. * Softer than {@link GENERATED_RANK_PENALTY} on purpose: "generated" is a claim
  408. * about provenance the file itself makes, while this is an inference about what
  409. * a file can be USEFUL for. A demoted declaration file that is still the best
  410. * candidate should keep its place; the penalty only has to stop it beating real
  411. * implementation. It does NOT stack with the generated penalty (see rankPenalty)
  412. * — penalising twice for the same property is how a file gets cliffed out of
  413. * answers where it is genuinely relevant.
  414. */
  415. const AMBIENT_DECLARATION_RANK_PENALTY = 0.5;
  416. /**
  417. * The type-level NodeKinds. Must stay in step with the kind list in
  418. * `QueryBuilder.getAmbientDeclarationPathsAmong` — that query decides which
  419. * files are ambient declarations, this set decides which symbols in them the
  420. * agent can name to lift the penalty back off.
  421. */
  422. const DECLARATION_KINDS = new Set(['interface', 'type_alias', 'enum', 'enum_member', 'namespace']);
  423. /**
  424. * Score floor: `clamp(topScore * FRACTION, ABSOLUTE, MAX)`.
  425. *
  426. * An absolute floor alone (`>= 3`) admits noise on any repo where the top file
  427. * scores 50+, so the bar is now a FRACTION of the best file's score and scales
  428. * with how strong the best match is. On a diffuse survey question no file
  429. * dominates, every candidate sits near the top score, and the whole spread gets
  430. * through; on a precise question it cuts the long tail of incidental matches.
  431. *
  432. * ABSOLUTE is recalibrated for kind-weighted scores: the old `>= 3` assumed an
  433. * unweighted tier sum where any query match was worth 10. A file whose sole
  434. * match is an unused local constant now scores 0.8, so 3 had quietly become a
  435. * much harsher admission bar than it was written to be — and the relative floor
  436. * is what this change means to prune with anyway.
  437. */
  438. const SCORE_FLOOR_ABSOLUTE = 1;
  439. const SCORE_FLOOR_FRACTION_OF_TOP = 0.2;
  440. /**
  441. * Ceiling on the relative floor, in units of one direct query match on a
  442. * callable (the `entryNodeIds` tier, weight 1.0). A single full-strength match
  443. * is never incidental, so no amount of concentration elsewhere may exclude it:
  444. * one named-seed-heavy file (`+50` per seed) otherwise pushed the floor to 21
  445. * and dropped `BridgeInterceptor`'s file, which the agent had named — a class,
  446. * so it entered at the +10 tier rather than +50. The #1500 noise this change
  447. * targets scores 0.8–6, well under this ceiling.
  448. */
  449. const SCORE_FLOOR_MAX = 10;
  450. /**
  451. * The relative floor must never starve a question of candidates: if it would
  452. * leave fewer than this, backfill with the best-scoring ones it cut. The cost of
  453. * under-serving is the agent calling explore again — a whole round-trip. See the
  454. * backfill itself for the two strengths it runs at (thin vs. empty).
  455. */
  456. const SCORE_FLOOR_KEEP_MIN = 3;
  457. // ── Score-proportional byte allocation (CG-12 / #1500) ─────────────────────
  458. //
  459. // The score floor above decides WHICH files reach the response. This decides how
  460. // the byte envelope is SPLIT among them — and until this existed, it wasn't
  461. // really decided at all: every admitted file was capped at the same
  462. // `maxCharsPerFile`, and the whole-file rule handed anything under
  463. // `maxCharsPerFile * 3` its entire contents. So allocation followed FILE SIZE,
  464. // not relevance. On this repo's own "how does explore allocate its output budget
  465. // across files", `src/mcp/tools.ts` (score 41, 4x the graph mass, 3x the distinct
  466. // term hits — it literally holds the allocator) was clipped at 3,800 while a
  467. // score-18 file shipped whole at 5,672 and took 51% of the envelope, purely for
  468. // being small. On the #1500 Go fixture, two generated CRUD files shipped whole at
  469. // ~4.5K each and consumed the tier's 4 file slots, so `BuildPayslip` — the
  470. // hand-written half of "create and calculate payslips" — never appeared at all.
  471. //
  472. // The replacement: reserve each file a share of the envelope proportional to what
  473. // it is worth, up front, before anything renders. Three consequences:
  474. //
  475. // 1. A reservation is a GUARANTEE, not a race. The old loop spent the envelope
  476. // first-come-first-served in rank order, so the top two files could exhaust
  477. // it and every later file hit a `budget-90pct` skip regardless of merit.
  478. // 2. A file below the cliff gets ZERO source — its path, symbols and line
  479. // numbers only. It costs ~100 chars instead of ~4,500, and (crucially) it
  480. // does not consume a `maxFiles` slot, so the slot goes to a file that earns
  481. // its bytes. This is the concentration lever.
  482. // 3. The per-file cap stops being the primary guard. It survives only as
  483. // `ALLOC_MAX_SHARE`, a safety valve against a single god-file — which the
  484. // proportional split already bounds, since a file's share can't exceed its
  485. // weight share.
  486. export const EXPLORE_ALLOCATION = {
  487. /**
  488. * A file whose weight is under this fraction of the top file's gets no source.
  489. *
  490. * Calibrated between the two shapes the fixtures pin: the #1500 generated CRUD
  491. * lands at 10–11% of the top weight (penalised twice — once into the score by
  492. * `rankPenalty`, once again here) and must cliff; a genuinely peripheral but
  493. * hand-written flow file — `payslip_builder.go`, the direct callee of the
  494. * workflow entry — lands at 25% and must NOT. Everything in between is a
  495. * judgement call the agent can undo for ~0 cost, because a cliffed file is
  496. * still NAMED in the response and one follow-up explore fetches it.
  497. */
  498. CLIFF_FRACTION: 0.15,
  499. /**
  500. * Ceiling on the cliff, in the same units as `SCORE_FLOOR_MAX` — and for the
  501. * same reason. A file whose weight clears a full-strength direct match is never
  502. * incidental, so no amount of concentration elsewhere may zero it: one
  503. * overwhelming top file (a 99-scoring god-file among score-10 peers) otherwise
  504. * puts the cliff at 14.9 and silences every peer the score floor had just
  505. * deliberately admitted. The cliff is a RELATIVE prune of weak evidence, not a
  506. * second admission gate — the score floor already owns admission.
  507. */
  508. CLIFF_MAX: SCORE_FLOOR_MAX,
  509. /**
  510. * Floor on a useful reservation — every admitted file gets this much before
  511. * the proportional split divides the rest. Under it a slice can't hold one
  512. * complete method, and a fragment is strictly worse than a pointer: it forces
  513. * the Read this tool exists to prevent.
  514. *
  515. * It is a FLOOR, not a second cliff. Cliffing the starved file instead
  516. * cascades: removing the smallest raises everyone else's share by so little
  517. * that the next-smallest starves too, and a query with two dominant files ate
  518. * six legitimately-ranked peers one at a time. Concentration is the relative
  519. * cliff's job; this only keeps a served file's slice usable.
  520. */
  521. MIN_CHARS: 700,
  522. /**
  523. * Safety valve, as a fraction of the envelope. Not the primary guard any more —
  524. * the proportional split is — so this only has to stop a pathological
  525. * single-file response.
  526. */
  527. MAX_SHARE: 0.7,
  528. /**
  529. * Markdown overhead charged per rendered file (header + fences + blank lines),
  530. * matching the render loop's own `+ 200` accounting. Held out of the pool
  531. * before the split so the reservations plus their overhead fit the envelope —
  532. * without this the last file's reservation is always the one that doesn't fit.
  533. */
  534. FILE_OVERHEAD: 200,
  535. /**
  536. * Flow-spine files are weighted up and are exempt from the cliff. Clipping the
  537. * spine causes the Read fallback (it IS the answer to a flow question);
  538. * clipping a peripheral file does not. This makes the existing advisory spine
  539. * handling — `hasSpine`, `SPINE_CEILING` — strict at the allocation layer.
  540. */
  541. SPINE_WEIGHT_BOOST: 2,
  542. /**
  543. * Slack allowed on the whole-file rule: a file a little over its reservation
  544. * still ships WHOLE rather than as clusters, because slicing off that last
  545. * sliver saves ~1% of the envelope and costs a Read — the trade the whole-file
  546. * rule exists to refuse. Proportional (with an absolute ceiling) because a
  547. * "sliver" is relative: a flat 800 is 15% of a 5K reservation but 31% of a 2.5K
  548. * one, and at the small end that overshoot is exactly what the file below then
  549. * loses.
  550. */
  551. WHOLE_FILE_GRACE_FRACTION: 0.15,
  552. WHOLE_FILE_GRACE_MAX: 800,
  553. /**
  554. * A reservation that already covers this fraction of a file BUYS THE WHOLE
  555. * FILE (CG-21), even though the file is bigger than the reservation.
  556. *
  557. * The grace above is calibrated as a *sliver* — it only rescues a file that
  558. * essentially fits. Below it there is a hole the render loop cannot fill:
  559. * express's `lib/utils.js` (5,293 B) was the TOP-ranked file, reserved 3,870,
  560. * declined the whole-file render at a 4,450 grace bound, and then spent 583 on
  561. * a three-symbol cluster render. The other 3,287 chars of its reservation were
  562. * neither redistributed nor delivered — the envelope shrank by a third against
  563. * an unchanged budget and the agent Read the file back four times.
  564. *
  565. * So the rule is not "does the file fit the reservation" but "has the
  566. * reservation already bought most of the file": at 0.6 the loop pays at most
  567. * two-thirds of a reservation extra to avoid losing the whole thing, and it
  568. * spends bytes it was going to spend anyway on a file that already earned
  569. * them. Below the fraction the shortfall is real — the file is several times
  570. * its reservation, clustering is the right answer, and the carry-forward
  571. * (`reservedSoFar`/`sourceSpent` in the render loop) hands whatever it cannot
  572. * spend to the next file down.
  573. */
  574. WHOLE_FILE_BUY_FRACTION: 0.6,
  575. /**
  576. * The buy rule's overshoot is funded from ONE pool for the whole response,
  577. * sized as this fraction of the envelope — deliberately the same 15% as
  578. * `WHOLE_FILE_GRACE_FRACTION`, one level up: the grace is a sliver of a
  579. * FILE's reservation, this is a sliver of the RESPONSE's envelope.
  580. *
  581. * Per-file funding is the version that fails, and it fails the same way the
  582. * bug being fixed does. The merit test is a RATIO, so wherever several files
  583. * sit near it they all qualify, and N independent overshoots inflate the
  584. * response until the render ceiling drops whatever is last. Measured on the
  585. * #1500 payroll fixture: three files bought whole and `payslip_builder.go` —
  586. * the file that computes the payslip the question asks about, rank #6 — was
  587. * dropped entirely so three higher-ranked files could each ship their final
  588. * sliver. A dropped section is strictly worse than a clustered one, so one
  589. * shared pool, spent in rank order, is the bound that matters.
  590. */
  591. WHOLE_FILE_BUY_OVERSHOOT_FRACTION: 0.15,
  592. } as const;
  593. /** One candidate file's allocation inputs, in final rank order. */
  594. export interface ExploreAllocationCandidate {
  595. path: string;
  596. /** Post-`rankPenalty` relevance score from the ranking pass. */
  597. score: number;
  598. /**
  599. * How much this file's BYTES are worth, independent of how well it matched.
  600. * Ranking answers "is this file about the query"; allocation answers "will
  601. * these bytes teach the agent anything". Generated CRUD can legitimately rank
  602. * (it name-collides on every domain word) while its bytes stay mechanical
  603. * boilerplate the agent gains nothing from reading — so `rankPenalty` is
  604. * applied a SECOND time here. That is what finally sinks the #1500 generated
  605. * layer below the cliff: it survived CG-10's single penalty because the sort's
  606. * leading keys (entry-point, graph mass) are structural, and a big densely
  607. * self-referential generated file scores well on both.
  608. */
  609. worth: number;
  610. /** Carries a symbol on the rendered flow spine. */
  611. spine: boolean;
  612. /**
  613. * The query named this file by PATH (see query-paths.ts). Pinned files are
  614. * never cliffed or trimmed, and weigh at least as much as the strongest
  615. * candidate — the agent asked for the file itself, so starving it on text/
  616. * graph scores (which a pure-path query doesn't produce) defeats the ask.
  617. */
  618. pinned?: boolean;
  619. }
  620. export interface ExploreAllocation {
  621. /** path → chars of source it may render. Only holds admitted files. */
  622. allowances: Map<string, number>;
  623. /** Files the cliff zeroed, in rank order — pointers, not bytes. */
  624. cliffed: string[];
  625. /** The weight threshold the cliff fired at (0 when nothing was cliffed). */
  626. cliffAt: number;
  627. /** Chars actually split among the admitted files. */
  628. pool: number;
  629. }
  630. /**
  631. * Split `budget.maxOutputChars` across ranked candidates in proportion to
  632. * relevance, with a hard relative cliff.
  633. *
  634. * `candidates` must arrive in FINAL RANK ORDER — `maxFiles` is applied to the
  635. * survivors of the cliff, in that order, so cliffing genuinely hands a slot to
  636. * the next file down rather than leaving it unused.
  637. *
  638. * Tier invariant (`getExploreOutputBudget`): a larger tier must never allow less
  639. * per file than a smaller one. It holds here by construction — every bound is a
  640. * fraction of `maxOutputChars` or of `maxCharsPerFile`, both monotonic across
  641. * tiers — except `MIN_CHARS`, which is an absolute floor and so identical at
  642. * every tier.
  643. */
  644. export function allocateExploreBudget(
  645. candidates: readonly ExploreAllocationCandidate[],
  646. budget: ExploreOutputBudget,
  647. maxFiles: number,
  648. ): ExploreAllocation {
  649. const A = EXPLORE_ALLOCATION;
  650. const empty: ExploreAllocation = { allowances: new Map(), cliffed: [], cliffAt: 0, pool: 0 };
  651. if (candidates.length === 0) return empty;
  652. // A non-finite weight is treated as no evidence rather than propagated: an
  653. // Infinity score would otherwise make every share `Infinity/Infinity` = NaN and
  654. // hand the render loop a NaN allowance. Scores are finite sums in the real
  655. // pipeline, so this only has to fail safe.
  656. const weightOf = (c: ExploreAllocationCandidate) => {
  657. const w = Math.max(0, c.score) * Math.max(0, Math.min(1, c.worth)) * (c.spine ? A.SPINE_WEIGHT_BOOST : 1);
  658. return Number.isFinite(w) ? w : 0;
  659. };
  660. // Pinned files weigh at least as much as the strongest raw candidate: their
  661. // score is whatever the stripped query happened to match (for a pure-path
  662. // query, nearly nothing), and a proportional split on that would fund the
  663. // named file worst of all. Floor of 1 covers the all-pinned/zero-score case.
  664. const rawWeights = new Map(candidates.map((c) => [c.path, weightOf(c)]));
  665. const topRaw = Math.max(...rawWeights.values());
  666. const weights = new Map(candidates.map((c) => [
  667. c.path,
  668. c.pinned ? Math.max(rawWeights.get(c.path) ?? 0, topRaw, 1) : (rawWeights.get(c.path) ?? 0),
  669. ]));
  670. const topWeight = Math.max(...weights.values());
  671. if (!(topWeight > 0)) return empty;
  672. // Cliff over the WHOLE candidate list, before `maxFiles` — otherwise the file
  673. // cap fills with cliff-bound files and the slot they free is never handed on.
  674. const cliffAt = Math.min(topWeight * A.CLIFF_FRACTION, A.CLIFF_MAX);
  675. const cliffed: string[] = [];
  676. let admitted: ExploreAllocationCandidate[] = [];
  677. for (const c of candidates) {
  678. if (!c.spine && !c.pinned && (weights.get(c.path) ?? 0) < cliffAt) cliffed.push(c.path);
  679. else admitted.push(c);
  680. }
  681. // Never cliff every candidate: an empty response costs a whole round-trip.
  682. if (admitted.length === 0) {
  683. admitted = [candidates[0]!];
  684. cliffed.splice(cliffed.indexOf(candidates[0]!.path), 1);
  685. }
  686. for (const c of admitted.slice(maxFiles)) cliffed.push(c.path);
  687. admitted = admitted.slice(0, maxFiles);
  688. // Serve fewer files well rather than many badly: the envelope has to afford
  689. // MIN_CHARS for everything admitted. When it can't, cliff the lowest-weight
  690. // files (never a spine file, never the last one) in one deterministic trim —
  691. // not one at a time, which is how the old starvation rule snowballed.
  692. const affordable = Math.max(1, Math.floor(budget.maxOutputChars / (A.MIN_CHARS + A.FILE_OVERHEAD)));
  693. if (admitted.length > affordable) {
  694. const byWeight = [...admitted].sort((a, b) => (weights.get(b.path) ?? 0) - (weights.get(a.path) ?? 0));
  695. const keep = new Set(byWeight.slice(0, affordable).map((c) => c.path));
  696. for (const c of admitted) if (c.spine || c.pinned) keep.add(c.path);
  697. for (const c of admitted) if (!keep.has(c.path)) cliffed.push(c.path);
  698. admitted = admitted.filter((c) => keep.has(c.path));
  699. }
  700. const allowances = new Map<string, number>();
  701. const pool = Math.max(0, budget.maxOutputChars - A.FILE_OVERHEAD * admitted.length);
  702. const total = admitted.reduce((s, c) => s + (weights.get(c.path) ?? 0), 0);
  703. if (total <= 0 || admitted.length === 0) return { allowances, cliffed, cliffAt, pool };
  704. // Everyone gets MIN_CHARS; the REMAINDER is what splits by weight. The floor
  705. // is what keeps a diffuse survey question returning a useful spread, and the
  706. // remainder is what concentrates a precise one — the top file's slice grows
  707. // with its weight share, uncapped by any flat per-file limit.
  708. const ceiling = Math.round(budget.maxOutputChars * A.MAX_SHARE);
  709. const floors = Math.min(pool, A.MIN_CHARS * admitted.length);
  710. const remainder = Math.max(0, pool - floors);
  711. // Both parts FLOOR: a sum of rounded shares can exceed the remainder that fed
  712. // it (by up to half a char per file), and the reservations must fit the pool
  713. // exactly — the render loop spends them, so an over-allocation is an over-long
  714. // response the hard ceiling then has to truncate. Flooring costs at most one
  715. // char per file.
  716. for (const c of admitted) {
  717. const share = Math.floor(floors / admitted.length)
  718. + Math.floor((remainder * (weights.get(c.path) ?? 0)) / total);
  719. allowances.set(c.path, Math.min(share, ceiling));
  720. }
  721. return { allowances, cliffed, cliffAt, pool };
  722. }
  723. /**
  724. * Whether `codegraph_explore` should prefix source lines with their line
  725. * numbers (cat -n style: `<num>\t<code>`).
  726. *
  727. * Line numbers let the agent cite `file:line` straight from the explore
  728. * payload instead of re-Reading the file just to find a line number — the
  729. * dominant residual cost on precise-tracing questions (#185 follow-up).
  730. *
  731. * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
  732. * A/B harness to measure the payload-cost vs. read-savings tradeoff).
  733. */
  734. function exploreLineNumbersEnabled(): boolean {
  735. return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
  736. }
  737. /**
  738. * Adaptive explore sizing (default ON). `codegraph_explore` skeletonizes OFF-SPINE
  739. * polymorphic-sibling files — a file whose class is one of ≥3 interchangeable
  740. * implementations of a shared interface (e.g. OkHttp's `: Interceptor` classes) —
  741. * to class + member signatures (bodies elided), keeping the on-spine exemplar full.
  742. * This sizes the response to the answer instead of the budget cap on sibling-heavy
  743. * flows (OkHttp interceptor-chain explore 28.5k→16.6k, ~28% cheaper than native
  744. * search, reads flat). It is PROVABLY INERT elsewhere: distinct pipeline steps (no
  745. * ≥3-implementer supertype, e.g. Excalidraw's `renderStaticScene`) and on-spine
  746. * files keep full source — output is byte-identical to shipped on excalidraw /
  747. * tokio / django / vscode / gin. Set `CODEGRAPH_ADAPTIVE_EXPLORE=0` to disable.
  748. */
  749. function adaptiveExploreEnabled(): boolean {
  750. return process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== '0' && process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== 'false';
  751. }
  752. /**
  753. * How long the FIRST tool call waits on the post-open catch-up reconcile before
  754. * giving up and serving anyway (issue #905). On a normal repo the reconcile
  755. * finishes in well under this, so the gate is fully honored and nothing changes.
  756. * On a very large repo (~100k files) the reconcile takes minutes — blocking the
  757. * first call on all of it presents as a multi-minute hang — so we wait briefly
  758. * for a clean answer, then serve and let the reconcile finish in the background
  759. * (it yields to the event loop, so a concurrent read still runs).
  760. *
  761. * `CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS` overrides the default; `0` restores the
  762. * old unbounded-wait behavior (always block until the reconcile completes).
  763. */
  764. const DEFAULT_CATCHUP_GATE_TIMEOUT_MS = 3000;
  765. function resolveCatchUpGateTimeoutMs(): number {
  766. const raw = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
  767. if (raw === undefined || raw === '') return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
  768. const n = Number(raw);
  769. if (!Number.isFinite(n) || n < 0) return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
  770. return Math.floor(n);
  771. }
  772. /**
  773. * Prefix each line of a source slice with its 1-based line number, matching
  774. * the Read tool's `cat -n` convention (number + tab) so the agent treats it
  775. * the same way it treats Read output.
  776. *
  777. * @param slice contiguous source text (already extracted from the file)
  778. * @param firstLineNumber the 1-based line number of the slice's first line
  779. */
  780. function numberSourceLines(slice: string, firstLineNumber: number): string {
  781. const out: string[] = [];
  782. const split = slice.split('\n');
  783. for (let i = 0; i < split.length; i++) {
  784. out.push(`${firstLineNumber + i}\t${split[i]}`);
  785. }
  786. return out.join('\n');
  787. }
  788. /**
  789. * Unique line-prefix for a per-file source section in codegraph_explore output.
  790. * Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
  791. * labels so Markdown-rendering MCP clients (e.g. the Claude Code VSCode
  792. * extension) stop blowing every header up to H1–H4. The path is bold + a code
  793. * span so it still reads as a header, and the leading ``**` `` stays a UNIQUE,
  794. * greppable marker — no other explore line begins with it — that the explore
  795. * truncation boundary (`handleExplore`) keys off to cut on whole file sections.
  796. */
  797. const FILE_SECTION_PREFIX = '**`';
  798. // Placeholder for codegraph_explore's "Found N symbols across M files." line.
  799. // The honest N/M can only be known after the final truncation drops trailing
  800. // sections (#1046), so the header is emitted as this sentinel and substituted
  801. // at the very end. This bracketed token never occurs in rendered source or a
  802. // file path, so the final string-replace can't collide.
  803. const SUMMARY_SENTINEL = '[[codegraph-explore-summary]]';
  804. function fileSectionHeader(filePath: string, suffix: string): string {
  805. return suffix
  806. ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
  807. : `${FILE_SECTION_PREFIX}${filePath}\`**`;
  808. }
  809. /** Header of `codegraph_explore`'s trailing pointer list. */
  810. const POINTER_HEADER = '**Not shown above — explore these names for their source**';
  811. /** Most files the pointer list ever names one-per-line; the rest are a count. */
  812. const POINTER_MAX_FILES = 10;
  813. /**
  814. * One pointer line: the file plus enough symbol names to make it NAMEABLE in a
  815. * follow-up explore. Capped — an un-capped list ran to ~1.9K on the #1500
  816. * fixture (12 generated CRUD symbols on one line), meta-text bought at the
  817. * price of the source bytes this section exists to point away from.
  818. */
  819. function pointerLineFor(filePath: string, nodes: readonly Node[]): string {
  820. const POINTER_SYMBOLS = 6;
  821. const named = nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export');
  822. const pool = named.length > 0 ? named : nodes;
  823. const shown = pool.slice(0, POINTER_SYMBOLS);
  824. const more = pool.length - shown.length;
  825. const symbols = shown.map((n) => `${n.name}:${n.startLine}`).join(', ')
  826. + (more > 0 ? `, +${more} more` : '');
  827. return `- ${filePath}: ${symbols}`;
  828. }
  829. /**
  830. * Emitted when the response was too full to carry ANY of its pointer list. It
  831. * is the one line the epilogue floor is reserved for: the list itself can be
  832. * traded away, but the agent must still be told that an uncovered area exists
  833. * and that another explore — not a Read — is how to reach it.
  834. */
  835. const EPILOGUE_LOST_NOTE = '> (Trailing pointer list omitted for size. The source above is complete and verbatim — treat it as already Read. For anything this call did not cover, run another codegraph_explore with the specific names rather than reading those files.)';
  836. /**
  837. * Per-file staleness banner emitted at the top of a tool response when the
  838. * file watcher has pending events for files referenced by the response.
  839. * The agent uses this to fall back to Read for those specific files
  840. * without waiting for the debounced sync (issue #403).
  841. */
  842. export function formatStaleBanner(stale: PendingFile[]): string {
  843. const now = Date.now();
  844. const lines = stale.map((p) => {
  845. const ageMs = Math.max(0, now - p.lastSeenMs);
  846. const label = p.indexing ? 'indexing in progress' : 'pending sync';
  847. return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
  848. });
  849. return (
  850. '⚠️ Some files referenced below were edited since the last index sync — ' +
  851. 'their codegraph entries may be stale:\n' +
  852. lines.join('\n') +
  853. '\nFor accurate content of those specific files, Read them directly. ' +
  854. 'The rest of this response is fresh.'
  855. );
  856. }
  857. /**
  858. * Compact footer listing pending files that are NOT referenced in this
  859. * response. Gives the agent a complete project-wide freshness picture
  860. * without bloating the main banner.
  861. */
  862. export function formatStaleFooter(stale: PendingFile[]): string {
  863. const MAX = 5;
  864. const now = Date.now();
  865. const shown = stale.slice(0, MAX);
  866. const lines = shown.map((p) => {
  867. const ageMs = Math.max(0, now - p.lastSeenMs);
  868. return ` - ${p.path} (edited ${ageMs}ms ago)`;
  869. });
  870. const more = stale.length > MAX ? `\n - …and ${stale.length - MAX} more` : '';
  871. return (
  872. `(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
  873. `sync but were not referenced above:\n${lines.join('\n')}${more})`
  874. );
  875. }
  876. /**
  877. * Whole-index degradation banner (issue #876). Emitted at the top of a read
  878. * tool response when live watching has permanently stopped — at which point
  879. * `getPendingFiles()` is empty, so the per-file banner above can't fire even
  880. * though the index is now FROZEN and silently drifting stale. Leads with the
  881. * agent-actionable instruction (Read directly) and carries the reason, which
  882. * already names the operator remedy (`codegraph sync` / git hooks).
  883. */
  884. export function formatDegradedBanner(reason: string | null): string {
  885. return (
  886. '⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the index is ' +
  887. 'frozen and any file edited since then is stale here. Read files directly to confirm ' +
  888. 'current content before relying on it.' +
  889. (reason ? `\n Reason: ${reason}` : '')
  890. );
  891. }
  892. /**
  893. * MCP Tool definition
  894. */
  895. export interface ToolDefinition {
  896. name: string;
  897. description: string;
  898. inputSchema: {
  899. type: 'object';
  900. properties: Record<string, PropertySchema>;
  901. required?: string[];
  902. };
  903. /** Behavioral hints for clients (see {@link ToolAnnotations}). */
  904. annotations?: ToolAnnotations;
  905. }
  906. /**
  907. * MCP ToolAnnotations — behavioral hints a client MAY use to decide how, or
  908. * whether, to run a tool (introduced in the 2025-03-26 spec, carried in
  909. * 2025-06-18). They are advisory and never to be trusted for security, but
  910. * clients gate on them: Cursor's Ask mode, for one, refuses any MCP tool that
  911. * doesn't advertise `readOnlyHint: true` (issue #1018).
  912. *
  913. * The field is purely additive — a client that predates annotations ignores it
  914. * — so codegraph advertises these even though `initialize` still negotiates the
  915. * 2024-11-05 protocol version.
  916. *
  917. * https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations
  918. */
  919. export interface ToolAnnotations {
  920. /** Human-readable title for the tool. */
  921. title?: string;
  922. /** If true, the tool does not modify its environment. Default (unset): false. */
  923. readOnlyHint?: boolean;
  924. /** Meaningful only when NOT read-only: may the tool perform destructive updates? */
  925. destructiveHint?: boolean;
  926. /** If true, repeat calls with the same arguments have no additional effect. */
  927. idempotentHint?: boolean;
  928. /** If true, the tool interacts with an open world of external entities. */
  929. openWorldHint?: boolean;
  930. }
  931. interface PropertySchema {
  932. type: string;
  933. description: string;
  934. enum?: string[];
  935. default?: unknown;
  936. }
  937. /**
  938. * Tool execution result
  939. */
  940. export interface ToolResult {
  941. content: Array<{
  942. type: 'text';
  943. text: string;
  944. }>;
  945. isError?: boolean;
  946. /**
  947. * INTERNAL side-channel (CG-17): what a `codegraph_explore` call actually put
  948. * on the wire — files, line ranges, bytes. It rides the result because the
  949. * call may have run on a query-pool worker, while the session state it feeds
  950. * lives on the main thread. {@link ToolHandler.execute} records it and DELETES
  951. * it, so nothing here ever reaches the client. Keyed by
  952. * {@link EXPLORE_EMISSION_KEY}; the two must stay in sync.
  953. */
  954. _cgExploreEmission?: ExploreEmission;
  955. }
  956. /**
  957. * Common projectPath property for cross-project queries
  958. */
  959. const projectPathProperty: PropertySchema = {
  960. type: 'string',
  961. description: 'Absolute path to the project to query (or any directory inside it) — codegraph uses the nearest .codegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
  962. };
  963. /**
  964. * EVERY codegraph tool is query-only: it reads the pre-built index and never
  965. * mutates the workspace (indexing is the user's explicit CLI call, never the
  966. * agent's). Advertising this read-only contract lets clients that gate on it run
  967. * the tools where a possibly-mutating tool would be blocked — most concretely,
  968. * Cursor's Ask mode, which rejects any MCP tool lacking `readOnlyHint: true`
  969. * (issue #1018). `idempotentHint`: a repeated query has no additional effect.
  970. * `openWorldHint: false`: the domain is the closed local index, not an open
  971. * external world. Shared so the contract is declared once; a hypothetical
  972. * mutating tool would simply not reference it.
  973. */
  974. const READ_ONLY_ANNOTATIONS: ToolAnnotations = {
  975. readOnlyHint: true,
  976. destructiveHint: false,
  977. idempotentHint: true,
  978. openWorldHint: false,
  979. };
  980. /**
  981. * All CodeGraph MCP tools
  982. *
  983. * Designed for minimal context usage - use codegraph_explore as the primary tool
  984. * (one call usually answers the whole question), and only use other tools for
  985. * targeted follow-up queries.
  986. *
  987. * All tools support cross-project queries via the optional `projectPath` parameter.
  988. */
  989. export const tools: ToolDefinition[] = [
  990. {
  991. name: 'codegraph_search',
  992. description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_explore instead to get the actual source / understand an area in one call.',
  993. inputSchema: {
  994. type: 'object',
  995. properties: {
  996. query: {
  997. type: 'string',
  998. description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
  999. },
  1000. kind: {
  1001. type: 'string',
  1002. description: 'Filter by node kind',
  1003. enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
  1004. },
  1005. limit: {
  1006. type: 'number',
  1007. description: 'Maximum results (default: 10)',
  1008. default: 10,
  1009. },
  1010. projectPath: projectPathProperty,
  1011. },
  1012. required: ['query'],
  1013. },
  1014. annotations: READ_ONLY_ANNOTATIONS,
  1015. },
  1016. {
  1017. name: 'codegraph_callers',
  1018. description: 'List functions that call <symbol>. For the full flow, use codegraph_explore.',
  1019. inputSchema: {
  1020. type: 'object',
  1021. properties: {
  1022. symbol: {
  1023. type: 'string',
  1024. description: 'Name of the function, method, or class to find callers for',
  1025. },
  1026. file: {
  1027. type: 'string',
  1028. description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist (e.g. one UserService per app in a monorepo)',
  1029. },
  1030. limit: {
  1031. type: 'number',
  1032. description: 'Maximum number of callers to return (default: 20)',
  1033. default: 20,
  1034. },
  1035. projectPath: projectPathProperty,
  1036. },
  1037. required: ['symbol'],
  1038. },
  1039. annotations: READ_ONLY_ANNOTATIONS,
  1040. },
  1041. {
  1042. name: 'codegraph_callees',
  1043. description: 'List functions that <symbol> calls. For the full flow, use codegraph_explore.',
  1044. inputSchema: {
  1045. type: 'object',
  1046. properties: {
  1047. symbol: {
  1048. type: 'string',
  1049. description: 'Name of the function, method, or class to find callees for',
  1050. },
  1051. file: {
  1052. type: 'string',
  1053. description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
  1054. },
  1055. limit: {
  1056. type: 'number',
  1057. description: 'Maximum number of callees to return (default: 20)',
  1058. default: 20,
  1059. },
  1060. projectPath: projectPathProperty,
  1061. },
  1062. required: ['symbol'],
  1063. },
  1064. annotations: READ_ONLY_ANNOTATIONS,
  1065. },
  1066. {
  1067. name: 'codegraph_impact',
  1068. description: 'List symbols affected by changing <symbol>. Use before a refactor.',
  1069. inputSchema: {
  1070. type: 'object',
  1071. properties: {
  1072. symbol: {
  1073. type: 'string',
  1074. description: 'Name of the symbol to analyze impact for',
  1075. },
  1076. file: {
  1077. type: 'string',
  1078. description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
  1079. },
  1080. depth: {
  1081. type: 'number',
  1082. description: 'How many levels of dependencies to traverse (default: 2)',
  1083. default: 2,
  1084. },
  1085. projectPath: projectPathProperty,
  1086. },
  1087. required: ['symbol'],
  1088. },
  1089. annotations: READ_ONLY_ANNOTATIONS,
  1090. },
  1091. {
  1092. name: 'codegraph_node',
  1093. description: 'Two modes. (1) READ A FILE — use INSTEAD of the Read tool: pass `file` (a path or basename) with no `symbol` and it returns that file\'s current on-disk source with line numbers, exactly the shape Read gives you (`<n>\\t<line>`, safe to Edit from), narrowable with `offset`/`limit` just like Read — PLUS a one-line note of which files depend on it. Same bytes as Read, faster (served from the index), with the blast radius attached. Use it whenever you would Read a source file. (2) ONE SYMBOL you can name — its location, signature, verbatim source (includeCode=true) and caller/callee trail in one call, so before changing it you see what calls it and what your edit would break. For an AMBIGUOUS name it returns EVERY matching definition\'s body in one call (so you never Read a file to find the right overload); pass `file`/`line` to pin one. Use codegraph_explore for several related symbols or the full flow.',
  1094. inputSchema: {
  1095. type: 'object',
  1096. properties: {
  1097. symbol: {
  1098. type: 'string',
  1099. description: 'Name of the symbol to read (symbol mode). Omit it and pass `file` alone to read a whole file like Read.',
  1100. },
  1101. includeCode: {
  1102. type: 'boolean',
  1103. description: 'Symbol mode: include the symbol\'s full body (default: false). Ignored in file mode, which always returns source unless `symbolsOnly` is set.',
  1104. default: false,
  1105. },
  1106. file: {
  1107. type: 'string',
  1108. description: 'A file path or basename (e.g. "harness.rs", "src/auth/session.ts"). Pass it ALONE (no symbol) to READ the file like the Read tool — its full source with line numbers + which files depend on it. Or pass it WITH a symbol to disambiguate an overloaded name to the definition in this file.',
  1109. },
  1110. offset: {
  1111. type: 'number',
  1112. description: 'File mode: 1-based line to start reading from, exactly like Read\'s offset. Defaults to the start of the file.',
  1113. },
  1114. limit: {
  1115. type: 'number',
  1116. description: 'File mode: maximum number of lines to return, exactly like Read\'s limit. Defaults to the whole file (capped at 2000 lines, like Read).',
  1117. },
  1118. symbolsOnly: {
  1119. type: 'boolean',
  1120. description: 'File mode: return just the file\'s symbol map + dependents (a cheap structural overview) instead of its source.',
  1121. default: false,
  1122. },
  1123. line: {
  1124. type: 'number',
  1125. description: 'Symbol mode only: disambiguate to the definition at/around this line (use with the file:line a trail showed you).',
  1126. },
  1127. projectPath: projectPathProperty,
  1128. },
  1129. required: [],
  1130. },
  1131. annotations: READ_ONLY_ANNOTATIONS,
  1132. },
  1133. {
  1134. name: 'codegraph_explore',
  1135. description: 'PRIMARY TOOL — call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.',
  1136. inputSchema: {
  1137. type: 'object',
  1138. properties: {
  1139. query: {
  1140. type: 'string',
  1141. description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too — no prior codegraph_search needed.',
  1142. },
  1143. maxFiles: {
  1144. type: 'number',
  1145. description: 'Maximum number of files to include source code from (default: 12)',
  1146. default: 12,
  1147. },
  1148. projectPath: projectPathProperty,
  1149. },
  1150. required: ['query'],
  1151. },
  1152. annotations: READ_ONLY_ANNOTATIONS,
  1153. },
  1154. {
  1155. name: 'codegraph_status',
  1156. description: 'Index health check (files / nodes / edges). Skip unless debugging.',
  1157. inputSchema: {
  1158. type: 'object',
  1159. properties: {
  1160. projectPath: projectPathProperty,
  1161. },
  1162. },
  1163. annotations: READ_ONLY_ANNOTATIONS,
  1164. },
  1165. {
  1166. name: 'codegraph_files',
  1167. description: 'Indexed file tree with language + symbol counts. Faster than Glob for project layout.',
  1168. inputSchema: {
  1169. type: 'object',
  1170. properties: {
  1171. path: {
  1172. type: 'string',
  1173. description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
  1174. },
  1175. pattern: {
  1176. type: 'string',
  1177. description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
  1178. },
  1179. format: {
  1180. type: 'string',
  1181. description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
  1182. enum: ['tree', 'flat', 'grouped'],
  1183. default: 'tree',
  1184. },
  1185. includeMetadata: {
  1186. type: 'boolean',
  1187. description: 'Include file metadata like language and symbol count (default: true)',
  1188. default: true,
  1189. },
  1190. maxDepth: {
  1191. type: 'number',
  1192. description: 'Maximum directory depth to show (default: unlimited)',
  1193. },
  1194. projectPath: projectPathProperty,
  1195. },
  1196. },
  1197. annotations: READ_ONLY_ANNOTATIONS,
  1198. },
  1199. ];
  1200. /**
  1201. * Return `defs` with `projectPath` marked `required` in each tool's inputSchema.
  1202. *
  1203. * Used for the NO-DEFAULT-PROJECT tool surface (issue #993): when the MCP server
  1204. * has no default project to fall back to — a gateway server started outside any
  1205. * repo, or a monorepo root whose `.codegraph/` indexes live only in sub-projects
  1206. * — every call MUST carry an explicit `projectPath`, so the schema should say so.
  1207. * A `required` field is a HIGH-salience channel (MCP clients surface and often
  1208. * validate it), unlike the instructions text the reporter found too weak to stop
  1209. * the agent omitting the param. When a default project IS open, callers leave
  1210. * projectPath optional and never call this.
  1211. *
  1212. * Pure: clones each tool's schema rather than mutating the shared module-level
  1213. * `tools` array (reused by every session and the static surface). A tool that
  1214. * doesn't expose projectPath, or already requires it, is returned untouched;
  1215. * explore's `['query']` becomes `['query', 'projectPath']`, and a tool with no
  1216. * `required` list (status/files) gains `['projectPath']`.
  1217. */
  1218. function withRequiredProjectPath(defs: ToolDefinition[]): ToolDefinition[] {
  1219. return defs.map((tool) => {
  1220. if (!tool.inputSchema.properties.projectPath) return tool;
  1221. const required = tool.inputSchema.required ?? [];
  1222. if (required.includes('projectPath')) return tool;
  1223. return {
  1224. ...tool,
  1225. inputSchema: { ...tool.inputSchema, required: [...required, 'projectPath'] },
  1226. };
  1227. });
  1228. }
  1229. /**
  1230. * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
  1231. * proxy answers `tools/list` with before any project is open. Mirrors
  1232. * `ToolHandler.getTools()` in the no-CodeGraph case (the dynamic per-repo budget
  1233. * note in a description only adds once `cg` is loaded; the schemas are static).
  1234. */
  1235. export function getStaticTools(): ToolDefinition[] {
  1236. const raw = process.env.CODEGRAPH_MCP_TOOLS;
  1237. if (!raw || !raw.trim()) {
  1238. return tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
  1239. }
  1240. const allow = new Set(raw.split(',').map(s => s.trim().replace(/^codegraph_/, '')).filter(Boolean));
  1241. return allow.size ? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, ''))) : tools;
  1242. }
  1243. /**
  1244. * The MCP tools served by DEFAULT (short names). Pared to ONLY `codegraph_explore`
  1245. * — the single tool that reliably earns its place: one capped call returns the
  1246. * verbatim source of the relevant symbols grouped by file. Every other tool is a
  1247. * narrower slice of what explore already does, and presence itself steers
  1248. * mis-picks, so they are no longer LISTED to agents.
  1249. *
  1250. * The other defined tools (`node`, `search`, `callers`, plus callees/impact/files/
  1251. * status) remain fully functional — handlers stay, the library API and CLI are
  1252. * untouched, and `CODEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them.
  1253. */
  1254. const DEFAULT_MCP_TOOLS = new Set(['explore']);
  1255. /**
  1256. * Tool handler that executes tools against a CodeGraph instance
  1257. *
  1258. * Supports cross-project queries via the projectPath parameter.
  1259. * Other projects are opened on-demand and cached for performance.
  1260. */
  1261. export class ToolHandler {
  1262. // Cache of opened CodeGraph instances for cross-project queries
  1263. private projectCache: Map<string, CodeGraph> = new Map();
  1264. // The directory the server last searched for a default project. Surfaced in
  1265. // the "not initialized" error so users can see why detection missed.
  1266. private defaultProjectHint: string | null = null;
  1267. // Indexed sub-projects the engine's bounded down-scan saw below the search
  1268. // base when no default project resolved (#1607). Listed in the "not
  1269. // initialized" error so the fact is reachable through the protocol, not just
  1270. // the host's stderr capture. Engine-maintained (initial resolve + throttled
  1271. // retry) — tool calls themselves never scan.
  1272. private knownSubprojects: string[] = [];
  1273. private knownSubprojectsBase: string | null = null;
  1274. // Per-start-path cache of the git worktree/index mismatch (issue #155). The
  1275. // mismatch is a fixed property of (where the request came from → which
  1276. // .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
  1277. // once and every later tool call reuses the result — never shelling out to
  1278. // git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
  1279. private worktreeMismatchCache: Map<string, WorktreeIndexMismatch | null> = new Map();
  1280. // Gate that the MCP engine pokes after `cg.open()` so the first tool call
  1281. // blocks on the post-open filesystem reconcile (catch-up sync). Without
  1282. // this, a tool call that races past `catchUpSync()` serves rows for files
  1283. // that were deleted (or edited) while no MCP server was running — and the
  1284. // per-file staleness banner can't help, because `getPendingFiles()` is
  1285. // populated by the watcher, not by catch-up. The wait is time-boxed
  1286. // (see {@link resolveCatchUpGateTimeoutMs}) so a minutes-long reconcile on a
  1287. // huge repo can't hang the first call (#905); cleared on first await so
  1288. // subsequent calls don't pay any cost.
  1289. private catchUpGate: Promise<void> | null = null;
  1290. // Optional worker-thread pool for off-loop read-tool dispatch (daemon mode).
  1291. // When set + healthy, the heavy read tools run on a worker so the daemon's
  1292. // main loop stays free for the MCP transport under concurrent load. Null in
  1293. // direct/in-process mode (one client, no concurrency to parallelize).
  1294. private queryPool: QueryPool | null = null;
  1295. constructor(private cg: CodeGraph | null) {}
  1296. /**
  1297. * Engine-only: attach (or detach with null) the worker-thread query pool. The
  1298. * shared daemon sets this once its default project is open; the workers each
  1299. * hold their own WAL read connection and run {@link executeReadTool}. A
  1300. * worker's own ToolHandler never has a pool, so there is no nested off-loading.
  1301. */
  1302. setQueryPool(pool: QueryPool | null): void {
  1303. this.queryPool = pool;
  1304. }
  1305. /**
  1306. * Update the default CodeGraph instance (e.g. after lazy initialization)
  1307. */
  1308. setDefaultCodeGraph(cg: CodeGraph): void {
  1309. this.cg = cg;
  1310. }
  1311. /**
  1312. * Engine-only: register the catch-up sync promise so the next `execute()`
  1313. * call awaits it before serving. The handler swallows rejections (the
  1314. * engine logs them) so a sync failure never propagates as a tool error;
  1315. * we still want to serve a best-effort result over the same potentially-
  1316. * stale data, which is what would have happened without the gate.
  1317. */
  1318. setCatchUpGate(p: Promise<void> | null): void {
  1319. this.catchUpGate = p;
  1320. }
  1321. /**
  1322. * Await the catch-up gate, but no longer than the configured timeout (#905).
  1323. * If the reconcile settles first, we got the fully-reconciled answer. If the
  1324. * timeout wins, we serve the call now and let the reconcile finish in the
  1325. * background — it yields to the event loop (see SYNC_RECONCILE_YIELD_INTERVAL),
  1326. * so a concurrent read still runs against the same connection. Never throws:
  1327. * a failed reconcile is logged by the engine, and we serve best-effort over
  1328. * the same potentially-stale data the un-gated path would have.
  1329. */
  1330. private async awaitCatchUpGate(gate: Promise<void>): Promise<void> {
  1331. const timeoutMs = resolveCatchUpGateTimeoutMs();
  1332. if (timeoutMs <= 0) {
  1333. // 0 = opt back into the original unbounded wait.
  1334. try { await gate; } catch { /* engine already logged */ }
  1335. return;
  1336. }
  1337. let timer: NodeJS.Timeout | undefined;
  1338. const timedOut = new Promise<'timeout'>((resolve) => {
  1339. timer = setTimeout(() => resolve('timeout'), timeoutMs);
  1340. timer.unref?.();
  1341. });
  1342. try {
  1343. const outcome = await Promise.race([
  1344. gate.then(() => 'done' as const, () => 'done' as const),
  1345. timedOut,
  1346. ]);
  1347. if (outcome === 'timeout') {
  1348. process.stderr.write(
  1349. `[CodeGraph MCP] Catch-up reconcile still running after ${timeoutMs}ms; serving this tool call now and finishing the reconcile in the background (#905). ` +
  1350. `Set CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 to always wait for it.\n`
  1351. );
  1352. }
  1353. } finally {
  1354. if (timer) clearTimeout(timer);
  1355. }
  1356. }
  1357. /**
  1358. * Record the directory the server tried to resolve the default project from.
  1359. * Used only to make the "no default project" error actionable.
  1360. */
  1361. setDefaultProjectHint(searchedPath: string): void {
  1362. this.defaultProjectHint = searchedPath;
  1363. }
  1364. /**
  1365. * Engine-only: record the indexed sub-projects the workspace down-scan saw
  1366. * when it could not adopt a default project (#1606/#1607). An empty list
  1367. * clears any previous note.
  1368. */
  1369. setKnownSubprojects(roots: string[], base: string): void {
  1370. this.knownSubprojects = roots;
  1371. this.knownSubprojectsBase = base;
  1372. }
  1373. /** One message line naming the indexed sub-projects, or '' when none known. */
  1374. private formatKnownSubprojects(): string {
  1375. if (this.knownSubprojects.length === 0) return '';
  1376. const base = this.knownSubprojectsBase;
  1377. const rels = this.knownSubprojects.map((r) => (base ? relativePath(base, r) || '.' : r));
  1378. return (
  1379. `Indexed sub-projects were found below it: ${rels.join(', ')} — ` +
  1380. 'pass one of them (absolute, or resolved against that directory) as projectPath.\n'
  1381. );
  1382. }
  1383. /**
  1384. * Whether a default CodeGraph instance is available
  1385. */
  1386. hasDefaultCodeGraph(): boolean {
  1387. return this.cg !== null;
  1388. }
  1389. /**
  1390. * Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
  1391. * env var (comma-separated short names, e.g. "trace,search,node,context").
  1392. * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
  1393. * trim the tool surface without rebuilding the client config; the ablated
  1394. * tool is then truly absent from ListTools rather than merely denied on call.
  1395. * Matching is on the short form, so "node" and "codegraph_node" both work.
  1396. */
  1397. private toolAllowlist(): Set<string> | null {
  1398. const raw = process.env.CODEGRAPH_MCP_TOOLS;
  1399. if (!raw || !raw.trim()) return null;
  1400. const short = (s: string) => s.trim().replace(/^codegraph_/, '');
  1401. const set = new Set(raw.split(',').map(short).filter(Boolean));
  1402. return set.size ? set : null;
  1403. }
  1404. /** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
  1405. private isToolAllowed(name: string): boolean {
  1406. const allow = this.toolAllowlist();
  1407. return !allow || allow.has(name.replace(/^codegraph_/, ''));
  1408. }
  1409. /**
  1410. * Get tool definitions with dynamic descriptions based on project size.
  1411. * The codegraph_explore tool description includes a budget recommendation
  1412. * scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
  1413. * allowlist so a trimmed surface is reflected in ListTools.
  1414. */
  1415. getTools(): ToolDefinition[] {
  1416. const allow = this.toolAllowlist();
  1417. // No explicit allowlist → the default 4-tool surface (see
  1418. // DEFAULT_MCP_TOOLS for the evidence). An allowlist replaces the
  1419. // default entirely, so any defined tool can be re-enabled.
  1420. let visible = allow
  1421. ? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
  1422. : tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
  1423. // No default project loaded → no-root-index case (#993): a gateway server
  1424. // started outside any repo, or a monorepo root whose indexes live in
  1425. // sub-projects. With nothing to fall back to, EVERY call needs an explicit
  1426. // projectPath, so mark it required in the schema — a high-salience nudge the
  1427. // agent acts on, where SERVER_INSTRUCTIONS_NO_ROOT_INDEX's prose alone
  1428. // wasn't enough (the reporter had to add an AGENTS.md note). `this.cg` is
  1429. // settled by `retryInitIfNeeded()` before `handleToolsList` calls us, so a
  1430. // null here means "genuinely no default", not a startup race. When a default
  1431. // IS open we leave projectPath optional (below): a bare call falls back to
  1432. // it, exactly as in the common single-project launch.
  1433. if (!this.cg) return withRequiredProjectPath(visible);
  1434. try {
  1435. const stats = this.cg.getStats();
  1436. const budget = getExploreBudget(stats.fileCount);
  1437. // Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
  1438. // files, only expose the core trio (search, node, explore) — one
  1439. // below even the 4-tool default: at this scale callers, too, reduces
  1440. // to one grep. (Historical note: the audit below ran when context and
  1441. // trace still existed; its "5 core tools" are today's trio.)
  1442. //
  1443. // n=2 audits ruled out cutting below 5 tools:
  1444. // - 3-tool gate (search + context + trace): cost regressed on
  1445. // cobra/ky/sinatra. The agent fell back to raw Reads to cover
  1446. // what codegraph_node + codegraph_explore would have answered.
  1447. // - 1-tool gate (search only): catastrophic regression — express
  1448. // went from -43% WIN to +107% LOSS. With only search, the agent
  1449. // can't navigate the call graph structurally and reads everything.
  1450. //
  1451. // 5 is the empirical lower bound. Tools beyond search/context/
  1452. // node/explore/trace pay overhead that the agent doesn't recoup
  1453. // on tiny-repo flow questions.
  1454. // ITER4: raise threshold 150 → 500 so single-file frameworks
  1455. // (sinatra at 159, slim_framework around 200) also get the
  1456. // 5-tool surface. The empirical 5-tool floor was set on <150
  1457. // probes; iter3 measurement showed sinatra is structurally the
  1458. // SAME problem as cobra (single-file WITHOUT-arm Read wins),
  1459. // so it deserves the same gating.
  1460. const TINY_REPO_FILE_THRESHOLD = 500;
  1461. const TINY_REPO_CORE_TOOLS = new Set([
  1462. 'codegraph_explore',
  1463. 'codegraph_search',
  1464. 'codegraph_node',
  1465. ]);
  1466. if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
  1467. visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
  1468. }
  1469. return visible.map(tool => {
  1470. if (tool.name === 'codegraph_explore') {
  1471. return {
  1472. ...tool,
  1473. description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
  1474. };
  1475. }
  1476. return tool;
  1477. });
  1478. } catch {
  1479. return visible;
  1480. }
  1481. }
  1482. /**
  1483. * Get CodeGraph instance for a project
  1484. *
  1485. * If projectPath is provided, opens that project's CodeGraph (cached).
  1486. * Otherwise returns the default CodeGraph instance.
  1487. *
  1488. * Walks up parent directories to find the nearest .codegraph/ folder,
  1489. * similar to how git finds .git/ directories.
  1490. */
  1491. private getCodeGraph(projectPath?: string): CodeGraph {
  1492. if (!projectPath) {
  1493. if (!this.cg) {
  1494. const searched = this.defaultProjectHint ?? process.cwd();
  1495. throw new NotIndexedError(
  1496. 'No CodeGraph project is loaded for this session.\n' +
  1497. `Searched for a .codegraph/ directory starting from: ${searched}\n` +
  1498. this.formatKnownSubprojects() +
  1499. 'Either the server root has no index of its own (e.g. a monorepo where only ' +
  1500. "sub-projects are indexed), or the MCP client launched the server outside your " +
  1501. 'project without reporting the workspace root. Either way, target the project ' +
  1502. 'explicitly:\n' +
  1503. ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' +
  1504. '(any project that has a .codegraph/ — including a sub-project of a monorepo)\n' +
  1505. ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
  1506. 'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' +
  1507. "project (the user can run 'codegraph init' there to enable it) — you can still query " +
  1508. 'other indexed projects by projectPath in the same session.'
  1509. );
  1510. }
  1511. return this.freshen(this.cg);
  1512. }
  1513. // Reject sensitive system directories before opening. Only validate a
  1514. // path that actually exists — a nested or not-yet-created sub-path of a
  1515. // real project must still be allowed to resolve UP to its .codegraph/
  1516. // root below (issue #238), so we don't run the existence-checking
  1517. // validator on paths that are meant to walk up.
  1518. if (existsSync(projectPath)) {
  1519. const pathError = validateProjectPath(projectPath);
  1520. if (pathError) {
  1521. throw new PathRefusalError(pathError);
  1522. }
  1523. }
  1524. // Always RE-RESOLVE the nearest .codegraph/ from the input path. The walk
  1525. // is cheap (a few existsSync up the tree) and is the only thing that
  1526. // notices a path whose index root CHANGED since it was first seen — most
  1527. // importantly a git worktree that gained its own .codegraph/ after the
  1528. // (long-lived) server first resolved it up to the parent checkout. We used
  1529. // to short-circuit on a `projectCache[projectPath]` entry before resolving,
  1530. // which pinned that first resolution for the server's whole lifetime, so a
  1531. // worktree kept being served the parent checkout's index until restart
  1532. // (#926). The DB connection itself is still cached (by resolved root,
  1533. // below), so re-resolving costs only the stat walk, never a reopen.
  1534. const resolvedRoot = findNearestCodeGraphRoot(projectPath);
  1535. if (!resolvedRoot) {
  1536. throw new NotIndexedError(
  1537. `The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
  1538. 'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
  1539. "for that codebase instead, and don't call codegraph for it again this session. " +
  1540. "Indexing is the user's decision — they can run 'codegraph init' in that project to enable it."
  1541. );
  1542. }
  1543. // If the path resolves to the default project, reuse the already-open
  1544. // default instance rather than opening a SECOND connection to the same DB.
  1545. // A duplicate connection serializes reads against the watcher's auto-sync
  1546. // writes; when WAL isn't in effect (e.g. a filesystem without shared-memory
  1547. // support) that surfaces as intermittent
  1548. // "database is locked" on concurrent tool calls. See issue #238. The
  1549. // default instance is owned/closed by the server, so it's never cached.
  1550. if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
  1551. return this.freshen(this.cg);
  1552. }
  1553. // Cache the open DB connection by RESOLVED ROOT only — never by the input
  1554. // path. One key per instance means closeAll() closes each exactly once, and
  1555. // a changed resolution maps to a different entry instead of a stale hit.
  1556. const cached = this.projectCache.get(resolvedRoot);
  1557. if (cached) return this.freshen(cached);
  1558. const cg = loadCodeGraph().openSync(resolvedRoot);
  1559. this.projectCache.set(resolvedRoot, cg);
  1560. return cg;
  1561. }
  1562. /**
  1563. * Heal a long-lived connection whose `.codegraph/` was removed and recreated
  1564. * at the same path (a worktree recreated, or `rm -rf .codegraph` + re-init)
  1565. * before handing it to a tool. Otherwise the daemon keeps serving the
  1566. * pre-removal snapshot from its now-unlinked file handle until restart — and
  1567. * because the daemon registry is keyed by path, a same-path recreate routes
  1568. * new clients straight back to this same stale daemon (#925). The check is one
  1569. * stat() and a no-op unless the inode actually changed; it never throws into a
  1570. * tool call.
  1571. */
  1572. private freshen(cg: CodeGraph): CodeGraph {
  1573. try {
  1574. if (cg.reopenIfReplaced()) {
  1575. process.stderr.write(
  1576. '[CodeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
  1577. 'recreated at the same path); reopened the live database in place.\n'
  1578. );
  1579. }
  1580. } catch {
  1581. // Best-effort self-heal — a failed reopen must never break the tool call;
  1582. // the (still stale) handle keeps serving and the next call retries.
  1583. }
  1584. return cg;
  1585. }
  1586. /**
  1587. * Close all cached project connections
  1588. */
  1589. closeAll(): void {
  1590. for (const cg of this.projectCache.values()) {
  1591. cg.close();
  1592. }
  1593. this.projectCache.clear();
  1594. this.worktreeMismatchCache.clear();
  1595. }
  1596. /**
  1597. * Validate that a value is a non-empty string within length bounds.
  1598. *
  1599. * The `maxLength` cap protects against MCP clients that ship huge
  1600. * payloads (10MB+ query strings either by accident or maliciously).
  1601. * Without this, a single oversized input can pin the FTS5 index or
  1602. * exhaust memory before any real work runs.
  1603. */
  1604. private validateString(
  1605. value: unknown,
  1606. name: string,
  1607. maxLength: number = MAX_INPUT_LENGTH
  1608. ): string | ToolResult {
  1609. if (typeof value !== 'string' || value.length === 0) {
  1610. return this.errorResult(`${name} must be a non-empty string`);
  1611. }
  1612. if (value.length > maxLength) {
  1613. return this.errorResult(
  1614. `${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`
  1615. );
  1616. }
  1617. return value;
  1618. }
  1619. /**
  1620. * Validate an optional path-like string input. Returns the value if
  1621. * valid (or undefined), or a ToolResult with the error.
  1622. */
  1623. private validateOptionalPath(
  1624. value: unknown,
  1625. name: string
  1626. ): string | undefined | ToolResult {
  1627. if (value === undefined || value === null) return undefined;
  1628. if (typeof value !== 'string') {
  1629. return this.errorResult(`${name} must be a string`);
  1630. }
  1631. if (value.length > MAX_PATH_LENGTH) {
  1632. return this.errorResult(
  1633. `${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`
  1634. );
  1635. }
  1636. return value;
  1637. }
  1638. /**
  1639. * Cached git worktree/index mismatch for a tool call's effective project.
  1640. *
  1641. * The "effective project" is what the request targets: an explicit
  1642. * `projectPath` arg, else the directory the server resolved its default
  1643. * project from (`defaultProjectHint`), else cwd. Memoized per start path —
  1644. * see `worktreeMismatchCache`. Best-effort: if the project can't be resolved
  1645. * (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
  1646. * broken by this check.
  1647. */
  1648. private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null {
  1649. const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
  1650. // The verdict depends on BOTH the start path AND the index root it resolves
  1651. // to, so the cache must be keyed on the pair. Resolve the index root first
  1652. // (cheap — getCodeGraph re-walks to the nearest .codegraph/, no git), then
  1653. // key on `(startPath, indexRoot)`. The moment that root changes — most
  1654. // importantly when a git worktree gains its own index and the walk-up stops
  1655. // there instead of at the parent checkout — the key changes and the verdict
  1656. // is recomputed, instead of serving the stale "borrowed the parent's index"
  1657. // warning for the server's whole lifetime. Keying on startPath alone pinned
  1658. // that first verdict until restart (#926).
  1659. let indexRoot: string;
  1660. try {
  1661. indexRoot = this.getCodeGraph(projectPath).getProjectRoot();
  1662. } catch {
  1663. // No resolvable project (or any other resolution error) → nothing to warn.
  1664. return null;
  1665. }
  1666. const cacheKey = `${startPath}\u0000${indexRoot}`;
  1667. const cached = this.worktreeMismatchCache.get(cacheKey);
  1668. if (cached !== undefined) return cached;
  1669. const mismatch = detectWorktreeIndexMismatch(startPath, indexRoot);
  1670. this.worktreeMismatchCache.set(cacheKey, mismatch);
  1671. return mismatch;
  1672. }
  1673. /**
  1674. * Prefix a successful read-tool result with a compact worktree-mismatch
  1675. * notice when the resolved index belongs to a different git working tree than
  1676. * the caller's (issue #155). Without this, an agent in a nested worktree
  1677. * silently trusts main-branch results. No-op on error results and when there
  1678. * is no mismatch. `codegraph_status` is excluded — it embeds its own verbose
  1679. * warning — so it stays out of this path.
  1680. */
  1681. private withWorktreeNotice(result: ToolResult, projectPath?: string): ToolResult {
  1682. if (result.isError) return result;
  1683. const mismatch = this.worktreeMismatchFor(projectPath);
  1684. if (!mismatch) return result;
  1685. const notice = worktreeMismatchNotice(mismatch);
  1686. const [first, ...rest] = result.content;
  1687. if (first && first.type === 'text') {
  1688. return { ...result, content: [{ type: 'text', text: `${notice}\n\n${first.text}` }, ...rest] };
  1689. }
  1690. return result;
  1691. }
  1692. /**
  1693. * Annotate a successful read-tool result with per-file staleness — the
  1694. * non-blocking answer to issue #403. The file watcher tracks every event
  1695. * it sees per path; here we intersect "files referenced in this response"
  1696. * against that pending set and prepend a compact banner so the agent can
  1697. * fall back to Read for those *specific* files without waiting for the
  1698. * debounced sync to fire. Other pending files in the project (not
  1699. * referenced by this response) get a small footer so the agent has a
  1700. * complete picture without bloating the banner.
  1701. *
  1702. * Cost when nothing is pending — the common case — is one boolean check.
  1703. * No I/O, no parsing of markdown beyond a per-pending-file substring scan.
  1704. */
  1705. private driftCache = new Map<string, { at: number; stale: boolean }>();
  1706. private static readonly DRIFT_TTL_MS = 2000;
  1707. /**
  1708. * On-disk drift check for a single indexed file (issue #1474). The code
  1709. * renderers slice CURRENT bytes at INDEXED line ranges; when the file
  1710. * changed after its last index sync those ranges can point at a DIFFERENT
  1711. * symbol's code — served under the requested name with `isError: false`.
  1712. * The watcher-based pending/degraded banners can't cover this for a
  1713. * project reached via `projectPath` (cross-project instances have no
  1714. * watcher, by construction), so freshness is verified here, at the point
  1715. * of emission, from data the index already stores.
  1716. *
  1717. * Cheap and precise: one stat() per file (size + mtime, the same
  1718. * comparison the sync fast path uses); only on a stat mismatch is the
  1719. * content hashed (sha256, matching extraction's `hashContent`) so a
  1720. * touch/checkout that rewrote identical bytes never false-positives.
  1721. * Results are memoized briefly so one response rendering the same file in
  1722. * several sections pays for the check once.
  1723. *
  1724. * Returns true when the on-disk file differs from what was indexed —
  1725. * i.e. indexed line ranges for it are NOT trustworthy. Any failure
  1726. * (missing files-table row, stat/read error) reports false: those cases
  1727. * are handled by the existing not-found paths, and a wrong "stale" flag
  1728. * would needlessly push the agent back to Read.
  1729. */
  1730. private isFileStaleOnDisk(cg: CodeGraph, relPath: string, content?: string): boolean {
  1731. let root: string;
  1732. try {
  1733. root = cg.getProjectRoot();
  1734. } catch {
  1735. return false;
  1736. }
  1737. const key = `${root}\0${relPath}`;
  1738. const now = Date.now();
  1739. const hit = this.driftCache.get(key);
  1740. if (hit && now - hit.at < ToolHandler.DRIFT_TTL_MS) return hit.stale;
  1741. let stale = false;
  1742. try {
  1743. const rec = cg.getFile(relPath);
  1744. const absPath = rec ? validatePathWithinRoot(root, relPath) : null;
  1745. if (rec && absPath && existsSync(absPath)) {
  1746. const st = statSync(absPath);
  1747. // Same freshness test as the sync fast path (extraction/index.ts):
  1748. // equal size + equal floored mtime ⇒ unchanged, no read needed.
  1749. if (st.size !== rec.size || Math.floor(st.mtimeMs) !== Math.floor(rec.modifiedAt)) {
  1750. const data = content ?? readFileSync(absPath, 'utf-8');
  1751. // Must stay byte-identical to extraction's `hashContent` (sha256 over
  1752. // the utf-8 string) — the identical-rewrite test in
  1753. // mcp-stale-slice.test.ts pins the parity. Inlined (not imported)
  1754. // to keep the extraction module off the MCP startup path.
  1755. stale = createHash('sha256').update(data).digest('hex') !== rec.contentHash;
  1756. }
  1757. }
  1758. } catch {
  1759. stale = false;
  1760. }
  1761. this.driftCache.set(key, { at: now, stale });
  1762. return stale;
  1763. }
  1764. private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult {
  1765. if (result.isError) return result;
  1766. let cg: CodeGraph;
  1767. try {
  1768. cg = this.getCodeGraph(projectPath);
  1769. } catch {
  1770. return result; // no default project — leave as is
  1771. }
  1772. // Cross-project `projectPath` calls open a cached CodeGraph WITHOUT a
  1773. // watcher (watchers are only attached to the default session project).
  1774. // When the cross-project path happens to be the same project as the
  1775. // default cg, the cached instance is the wrong one — its pendingFiles is
  1776. // permanently empty. Detect the equal-path case and prefer the default
  1777. // cg so the staleness signal still fires when an agent passes the
  1778. // explicit projectPath form of its own project.
  1779. if (this.cg && cg !== this.cg) {
  1780. try {
  1781. const sameProject =
  1782. resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot());
  1783. if (sameProject) cg = this.cg;
  1784. } catch {
  1785. /* getProjectRoot may throw on a closed instance — leave cg as is */
  1786. }
  1787. }
  1788. // Whole-index degradation (#876): once live watching has permanently
  1789. // stopped, getPendingFiles() is empty so the per-file banner below can't
  1790. // fire — but the index is now FROZEN and silently drifting stale. Surface
  1791. // one global notice instead, so the agent Reads for current content rather
  1792. // than trusting a response off a no-longer-updating index. (Cross-project
  1793. // calls open a watcher-less CodeGraph, so this is false there — correct: we
  1794. // only know degraded state for the default session project.)
  1795. let degraded = false;
  1796. try {
  1797. degraded = cg.isWatcherDegraded?.() ?? false;
  1798. } catch {
  1799. degraded = false;
  1800. }
  1801. if (degraded) {
  1802. const [head, ...tail] = result.content;
  1803. if (!head || head.type !== 'text') return result;
  1804. let reason: string | null = null;
  1805. try {
  1806. reason = cg.getWatcherDegradedReason?.() ?? null;
  1807. } catch {
  1808. reason = null;
  1809. }
  1810. const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
  1811. return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
  1812. }
  1813. // Defensive: some test fakes inject a partial CodeGraph stub without the
  1814. // newer pending-files API. Treat missing/throwing as "no pending files."
  1815. let pending: PendingFile[] = [];
  1816. try {
  1817. pending = cg.getPendingFiles?.() ?? [];
  1818. } catch {
  1819. return result;
  1820. }
  1821. if (pending.length === 0) return result;
  1822. const [first, ...rest] = result.content;
  1823. if (!first || first.type !== 'text') return result;
  1824. const text = first.text;
  1825. const inResponse: PendingFile[] = [];
  1826. const elsewhere: PendingFile[] = [];
  1827. for (const p of pending) {
  1828. // Substring match against the project-relative POSIX path — that's
  1829. // exactly the format both the watcher and every codegraph response
  1830. // emit, so a plain includes() is sufficient and avoids regex pitfalls.
  1831. if (text.includes(p.path)) inResponse.push(p);
  1832. else elsewhere.push(p);
  1833. }
  1834. let banner = '';
  1835. if (inResponse.length > 0) {
  1836. banner = formatStaleBanner(inResponse);
  1837. }
  1838. let footer = '';
  1839. if (elsewhere.length > 0) {
  1840. footer = formatStaleFooter(elsewhere);
  1841. }
  1842. if (!banner && !footer) return result;
  1843. const composed = [banner, text, footer].filter(Boolean).join('\n\n');
  1844. return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
  1845. }
  1846. /**
  1847. * Execute a tool by name.
  1848. *
  1849. * `sessionState` is the CALLER's per-session explore history (CG-17). The
  1850. * daemon shares one ToolHandler across every connected session, so this state
  1851. * cannot live on the handler — each session owns one and hands it in, which is
  1852. * what keeps two sessions on one daemon from ever seeing each other's calls.
  1853. * Omit it (the CLI does) and explore behaves exactly as before, untracked.
  1854. */
  1855. async execute(
  1856. toolName: string,
  1857. args: Record<string, unknown>,
  1858. sessionState?: ExploreSessionState,
  1859. ): Promise<ToolResult> {
  1860. try {
  1861. // Block the first tool call on the engine's post-open reconcile so we
  1862. // never serve rows for files deleted/edited while no MCP server was
  1863. // running. The wait is time-boxed (#905): a huge-repo reconcile takes
  1864. // minutes, and blocking the first call on all of it reads as a hang, so
  1865. // we wait briefly then serve and let it finish in the background. The
  1866. // gate is cleared after first await — subsequent calls pay nothing.
  1867. // Catch-up failures are logged by the engine; we proceed regardless so a
  1868. // transient sync error never breaks tools.
  1869. if (this.catchUpGate) {
  1870. const gate = this.catchUpGate;
  1871. this.catchUpGate = null;
  1872. await this.awaitCatchUpGate(gate);
  1873. }
  1874. // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
  1875. // surface rejects ablated tools defensively even if a client cached them.
  1876. if (!this.isToolAllowed(toolName)) {
  1877. return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
  1878. }
  1879. // Cross-cutting input validation. All tools accept an optional
  1880. // `projectPath` and most accept either `query`, `task`, or
  1881. // `symbol` — bound their lengths centrally so individual handlers
  1882. // can stay focused on tool-specific logic.
  1883. const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
  1884. if (typeof pathCheck === 'object' && pathCheck !== undefined) {
  1885. return pathCheck;
  1886. }
  1887. // The `path` and `pattern` properties used by codegraph_files are
  1888. // also path-shaped — apply the same cap.
  1889. if (args.path !== undefined) {
  1890. const check = this.validateOptionalPath(args.path, 'path');
  1891. if (typeof check === 'object' && check !== undefined) return check;
  1892. }
  1893. if (args.pattern !== undefined) {
  1894. const check = this.validateOptionalPath(args.pattern, 'pattern');
  1895. if (typeof check === 'object' && check !== undefined) return check;
  1896. }
  1897. // codegraph_status reports watcher state (pending files, degraded mode,
  1898. // worktree warning) and embeds its own sections — it must run on the MAIN
  1899. // thread against the watched default instance, so it is NEVER off-loaded to
  1900. // a worker (whose read connection has no watcher). It also skips the
  1901. // auto-banner wrapper to avoid duplicating its own pending-files section.
  1902. if (toolName === 'codegraph_status') {
  1903. return await this.handleStatus(args);
  1904. }
  1905. // Read tools: off-load the CPU-heavy dispatch to the worker pool when one
  1906. // is attached, healthy, AND has finished its first cold start (daemon
  1907. // mode), so the daemon's single event loop stays free for the MCP
  1908. // transport under concurrent load — otherwise N concurrent explores
  1909. // serialize AND starve the transport until the whole batch drains
  1910. // (clients then time out). Before the first worker is warm, calls run
  1911. // in-process: a call queued behind a cold start sat invisible until the
  1912. // 45s busy backstop — the daemon's first tool call stalling for however
  1913. // long a worker spawn takes on a loaded machine (the #662 flake). With
  1914. // no pool (direct mode) or a degraded one, dispatch runs in-process
  1915. // exactly as before. Either way the result flows through the
  1916. // cross-cutting notices — worktree-index mismatch (#155) and per-file
  1917. // staleness (#403) — which need the watched MAIN instance and so are
  1918. // always applied here, never in the worker.
  1919. //
  1920. // Explore also carries the session's own call history down (CG-17) and its
  1921. // emission record back up. Both travel as plain properties — on the args
  1922. // object down, on the ToolResult up — because either leg may cross a
  1923. // structured-clone boundary into a worker, where a closure or a handler
  1924. // field could not follow.
  1925. const dispatchArgs = this.withSessionView(toolName, args, sessionState);
  1926. const raw = (this.queryPool && this.queryPool.healthy && this.queryPool.ready)
  1927. ? await this.queryPool.run(toolName, dispatchArgs)
  1928. : await this.executeReadTool(toolName, dispatchArgs);
  1929. // Record + STRIP before anything else touches the result: the emission is
  1930. // internal bookkeeping and must never reach the client, whether or not a
  1931. // caller passed session state.
  1932. const result = this.takeExploreEmission(raw, sessionState);
  1933. const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
  1934. return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
  1935. } catch (err) {
  1936. // Expected condition, not a malfunction: answer as a SUCCESS so the
  1937. // agent keeps trusting the toolset for projects that ARE indexed.
  1938. // (An isError here teaches session-long abandonment — see NotIndexedError.)
  1939. if (err instanceof NotIndexedError) {
  1940. return this.textResult(err.message);
  1941. }
  1942. // Security refusal: a clean error, no retry encouragement.
  1943. if (err instanceof PathRefusalError) {
  1944. return this.errorResult(err.message);
  1945. }
  1946. return this.errorResult(
  1947. `Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
  1948. 'This is an internal codegraph error — retry the call once; if it persists, ' +
  1949. 'continue without codegraph for this task.'
  1950. );
  1951. }
  1952. }
  1953. /**
  1954. * Attach the caller's session view to an explore call's args (CG-17), on a
  1955. * COPY so the caller's object is never mutated. Nothing else sees it: a
  1956. * non-explore tool, or a caller with no session state, gets the args
  1957. * unchanged and pays nothing.
  1958. *
  1959. * A client that spells the internal key itself is stripped rather than
  1960. * trusted — the view decides what source a later call may withhold, so it has
  1961. * to come from the server's own record, never from the wire.
  1962. */
  1963. private withSessionView(
  1964. toolName: string,
  1965. args: Record<string, unknown>,
  1966. sessionState: ExploreSessionState | undefined,
  1967. ): Record<string, unknown> {
  1968. if (!(EXPLORE_SESSION_VIEW_ARG in args) && (!sessionState || toolName !== 'codegraph_explore')) {
  1969. return args;
  1970. }
  1971. const copy = { ...args };
  1972. delete copy[EXPLORE_SESSION_VIEW_ARG];
  1973. if (sessionState && toolName === 'codegraph_explore') {
  1974. copy[EXPLORE_SESSION_VIEW_ARG] = sessionState.view();
  1975. }
  1976. return copy;
  1977. }
  1978. /**
  1979. * Record an explore call's emission into the caller's session state and strip
  1980. * it from the result (CG-17).
  1981. *
  1982. * Unconditional strip: the property is internal, so it comes off even when
  1983. * there is no session state to record it into (the CLI path) — that is what
  1984. * keeps the agent-facing response byte-identical. Recording is wrapped
  1985. * because a bookkeeping bug must never fail a tool call that already
  1986. * succeeded.
  1987. */
  1988. private takeExploreEmission(
  1989. result: ToolResult,
  1990. sessionState: ExploreSessionState | undefined,
  1991. ): ToolResult {
  1992. const emission = result?.[EXPLORE_EMISSION_KEY];
  1993. if (emission === undefined) return result;
  1994. delete result[EXPLORE_EMISSION_KEY];
  1995. if (sessionState) {
  1996. try {
  1997. sessionState.record(emission);
  1998. } catch { /* bookkeeping only — never fail a served call */ }
  1999. }
  2000. return result;
  2001. }
  2002. /**
  2003. * Run a single read tool to completion and return its raw {@link ToolResult},
  2004. * classifying expected failures the same way {@link execute}'s catch does so
  2005. * the SHAPE is identical whether dispatch runs in-process or on a worker:
  2006. * NotIndexed → success-shaped guidance, PathRefusal → clean error, anything
  2007. * else → internal-error-with-retry. Never throws.
  2008. *
  2009. * This is the worker thread's entry point (see {@link ./query-worker}) and the
  2010. * in-process fallback for {@link execute}. It deliberately does NOT run the
  2011. * catch-up gate or the staleness/worktree notices — those need the daemon's
  2012. * watched main instance and stay on the main thread. Cross-cutting allowlist +
  2013. * path validation already ran in {@link execute} before routing here.
  2014. */
  2015. async executeReadTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  2016. try {
  2017. return await this.dispatchTool(toolName, args);
  2018. } catch (err) {
  2019. if (err instanceof NotIndexedError) {
  2020. return this.textResult(err.message);
  2021. }
  2022. if (err instanceof PathRefusalError) {
  2023. return this.errorResult(err.message);
  2024. }
  2025. return this.errorResult(
  2026. `Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
  2027. 'This is an internal codegraph error — retry the call once; if it persists, ' +
  2028. 'continue without codegraph for this task.'
  2029. );
  2030. }
  2031. }
  2032. /**
  2033. * Pure dispatch over the read tools — the switch, with no gate, no notices, no
  2034. * allowlist/validation (the caller owns those). `codegraph_status` is handled
  2035. * on the main thread in {@link execute} and never reaches here. May throw
  2036. * NotIndexed/PathRefusal, which {@link executeReadTool} classifies.
  2037. */
  2038. private async dispatchTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  2039. switch (toolName) {
  2040. case 'codegraph_search': return await this.handleSearch(args);
  2041. case 'codegraph_callers': return await this.handleCallers(args);
  2042. case 'codegraph_callees': return await this.handleCallees(args);
  2043. case 'codegraph_impact': return await this.handleImpact(args);
  2044. case 'codegraph_explore': return await this.handleExplore(args);
  2045. case 'codegraph_node': return await this.handleNode(args);
  2046. case 'codegraph_files': return await this.handleFiles(args);
  2047. default: return this.errorResult(`Unknown tool: ${toolName}`);
  2048. }
  2049. }
  2050. /**
  2051. * Handle codegraph_search
  2052. */
  2053. private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
  2054. const query = this.validateString(args.query, 'query');
  2055. if (typeof query !== 'string') return query;
  2056. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  2057. const rawKind = args.kind as string | undefined;
  2058. // The schema enum says 'type' (what agents naturally reach for); the
  2059. // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
  2060. // matched nothing — a filter value we advertise must work.
  2061. const kind = rawKind === 'type' ? 'type_alias' : rawKind;
  2062. const rawLimit = Number(args.limit) || 10;
  2063. const limit = clamp(rawLimit, 1, 100);
  2064. const results = cg.searchNodes(query, {
  2065. limit,
  2066. kinds: kind ? [kind as NodeKind] : undefined,
  2067. });
  2068. if (results.length === 0) {
  2069. return this.textResult(`No results found for "${query}"`);
  2070. }
  2071. // Down-rank generated files within the FTS-returned set so a search
  2072. // for "Send" surfaces the hand-written keeper before .pb.go stubs
  2073. // that share the name. Stable: only reorders generated vs. not.
  2074. const isGen = cg.generatedFilePredicate(results.map((r) => r.node.filePath));
  2075. const ranked = [...results].sort((a, b) => {
  2076. const aGen = isGen(a.node.filePath) ? 1 : 0;
  2077. const bGen = isGen(b.node.filePath) ? 1 : 0;
  2078. return aGen - bGen;
  2079. });
  2080. const formatted = this.formatSearchResults(ranked);
  2081. return this.textResult(this.truncateOutput(formatted));
  2082. }
  2083. /**
  2084. * Group symbol matches into DISTINCT DEFINITIONS — one group per
  2085. * (filePath, qualifiedName), so same-file overloads stay together while
  2086. * unrelated same-named classes across a monorepo's apps (#764: one
  2087. * `UserService` per NestJS app) are kept apart. Optionally narrowed by a
  2088. * `file` path/suffix first.
  2089. */
  2090. private groupDefinitions(
  2091. nodes: Node[],
  2092. fileFilter: string | undefined
  2093. ): { groups: Node[][]; filteredOut: boolean } {
  2094. let pool = nodes;
  2095. let filteredOut = false;
  2096. if (fileFilter) {
  2097. const wanted = fileFilter.replace(/^\.\//, '');
  2098. const narrowed = pool.filter(
  2099. (n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`)
  2100. );
  2101. if (narrowed.length > 0) {
  2102. pool = narrowed;
  2103. } else {
  2104. filteredOut = true;
  2105. }
  2106. }
  2107. const byDef = new Map<string, Node[]>();
  2108. for (const n of pool) {
  2109. const key = `${n.filePath}|${n.qualifiedName}`;
  2110. const group = byDef.get(key);
  2111. if (group) group.push(n);
  2112. else byDef.set(key, [n]);
  2113. }
  2114. return { groups: [...byDef.values()], filteredOut };
  2115. }
  2116. /** Section heading for one distinct definition in grouped output. */
  2117. private definitionHeading(group: Node[]): string {
  2118. const head = group[0]!;
  2119. const line = head.startLine ? `:${head.startLine}` : '';
  2120. return `**${head.qualifiedName}** (${head.kind}) — ${head.filePath}${line}`;
  2121. }
  2122. /**
  2123. * Handle codegraph_callers
  2124. */
  2125. private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
  2126. const symbol = this.validateString(args.symbol, 'symbol');
  2127. if (typeof symbol !== 'string') return symbol;
  2128. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  2129. const limit = clamp((args.limit as number) || 20, 1, 100);
  2130. const fileFilter = typeof args.file === 'string' ? args.file : undefined;
  2131. const allMatches = this.findAllSymbols(cg, symbol);
  2132. if (allMatches.nodes.length === 0) {
  2133. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  2134. }
  2135. const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
  2136. const filterNote = filteredOut
  2137. ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
  2138. : '';
  2139. const collect = (defNodes: Node[]) => {
  2140. const seen = new Set<string>();
  2141. const callers: Node[] = [];
  2142. const labels = new Map<string, string>();
  2143. for (const node of defNodes) {
  2144. for (const c of cg.getCallers(node.id)) {
  2145. if (!seen.has(c.node.id)) {
  2146. seen.add(c.node.id);
  2147. callers.push(c.node);
  2148. const label = this.edgeLabel(c.edge);
  2149. if (label) labels.set(c.node.id, label);
  2150. }
  2151. }
  2152. }
  2153. return { callers, labels };
  2154. };
  2155. // Single definition (or same-file overloads): the familiar flat list.
  2156. if (groups.length === 1) {
  2157. const { callers, labels } = collect(groups[0]!);
  2158. if (callers.length === 0) {
  2159. return this.textResult(`No callers found for "${symbol}"${allMatches.note}${filterNote}`);
  2160. }
  2161. // A successful `file` narrowing makes the multi-symbol aggregation note
  2162. // stale — suppress it.
  2163. const note = fileFilter && !filteredOut ? '' : allMatches.note;
  2164. const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote;
  2165. return this.textResult(this.truncateOutput(formatted));
  2166. }
  2167. // Multiple DISTINCT definitions (#764): one section per definition so an
  2168. // agent never mistakes one app's callers for another's. Narrow with
  2169. // `file` to focus a single definition.
  2170. const lines: string[] = [
  2171. `**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
  2172. ];
  2173. for (const group of groups) {
  2174. const { callers, labels } = collect(group);
  2175. lines.push('', this.definitionHeading(group));
  2176. if (callers.length === 0) {
  2177. lines.push('- (no callers)');
  2178. continue;
  2179. }
  2180. for (const node of callers.slice(0, limit)) {
  2181. const location = node.startLine ? `:${node.startLine}` : '';
  2182. const label = labels.get(node.id);
  2183. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
  2184. }
  2185. }
  2186. return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
  2187. }
  2188. /**
  2189. * Handle codegraph_callees
  2190. */
  2191. private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
  2192. const symbol = this.validateString(args.symbol, 'symbol');
  2193. if (typeof symbol !== 'string') return symbol;
  2194. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  2195. const limit = clamp((args.limit as number) || 20, 1, 100);
  2196. const fileFilter = typeof args.file === 'string' ? args.file : undefined;
  2197. const allMatches = this.findAllSymbols(cg, symbol);
  2198. if (allMatches.nodes.length === 0) {
  2199. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  2200. }
  2201. const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
  2202. const filterNote = filteredOut
  2203. ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
  2204. : '';
  2205. const collect = (defNodes: Node[]) => {
  2206. const seen = new Set<string>();
  2207. const callees: Node[] = [];
  2208. const labels = new Map<string, string>();
  2209. for (const node of defNodes) {
  2210. for (const c of cg.getCallees(node.id)) {
  2211. if (!seen.has(c.node.id)) {
  2212. seen.add(c.node.id);
  2213. callees.push(c.node);
  2214. const label = this.edgeLabel(c.edge);
  2215. if (label) labels.set(c.node.id, label);
  2216. }
  2217. }
  2218. }
  2219. return { callees, labels };
  2220. };
  2221. if (groups.length === 1) {
  2222. const { callees, labels } = collect(groups[0]!);
  2223. if (callees.length === 0) {
  2224. return this.textResult(`No callees found for "${symbol}"${allMatches.note}${filterNote}`);
  2225. }
  2226. // A successful `file` narrowing makes the multi-symbol aggregation note
  2227. // stale — suppress it.
  2228. const note = fileFilter && !filteredOut ? '' : allMatches.note;
  2229. const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote;
  2230. return this.textResult(this.truncateOutput(formatted));
  2231. }
  2232. // Multiple DISTINCT definitions (#764): per-definition sections.
  2233. const lines: string[] = [
  2234. `**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
  2235. ];
  2236. for (const group of groups) {
  2237. const { callees, labels } = collect(group);
  2238. lines.push('', this.definitionHeading(group));
  2239. if (callees.length === 0) {
  2240. lines.push('- (no callees)');
  2241. continue;
  2242. }
  2243. for (const node of callees.slice(0, limit)) {
  2244. const location = node.startLine ? `:${node.startLine}` : '';
  2245. const label = labels.get(node.id);
  2246. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
  2247. }
  2248. }
  2249. return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
  2250. }
  2251. /**
  2252. * Handle codegraph_impact
  2253. */
  2254. private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
  2255. const symbol = this.validateString(args.symbol, 'symbol');
  2256. if (typeof symbol !== 'string') return symbol;
  2257. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  2258. const depth = clamp((args.depth as number) || 2, 1, 10);
  2259. const fileFilter = typeof args.file === 'string' ? args.file : undefined;
  2260. const allMatches = this.findAllSymbols(cg, symbol);
  2261. if (allMatches.nodes.length === 0) {
  2262. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  2263. }
  2264. const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
  2265. const filterNote = filteredOut
  2266. ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
  2267. : '';
  2268. const impactOf = (defNodes: Node[]) => {
  2269. const mergedNodes = new Map<string, Node>();
  2270. const mergedEdges: Edge[] = [];
  2271. const seenEdges = new Set<string>();
  2272. for (const node of defNodes) {
  2273. const impact = cg.getImpactRadius(node.id, depth);
  2274. for (const [id, n] of impact.nodes) {
  2275. mergedNodes.set(id, n);
  2276. }
  2277. for (const e of impact.edges) {
  2278. const key = `${e.source}->${e.target}:${e.kind}`;
  2279. if (!seenEdges.has(key)) {
  2280. seenEdges.add(key);
  2281. mergedEdges.push(e);
  2282. }
  2283. }
  2284. }
  2285. return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) };
  2286. };
  2287. // Single definition (or same-file overloads): the familiar merged report.
  2288. if (groups.length === 1) {
  2289. const formatted = this.formatImpact(symbol, impactOf(groups[0]!)) + (fileFilter && !filteredOut ? "" : allMatches.note) + filterNote;
  2290. return this.textResult(this.truncateOutput(formatted));
  2291. }
  2292. // Multiple DISTINCT definitions (#764): a blast radius PER definition —
  2293. // merging unrelated same-named classes (one UserService per monorepo app)
  2294. // overstated impact and confused agents. Narrow with `file`.
  2295. const sections: string[] = [
  2296. `**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`,
  2297. ];
  2298. for (const group of groups) {
  2299. const head = group[0]!;
  2300. const line = head.startLine ? `:${head.startLine}` : '';
  2301. sections.push(
  2302. '',
  2303. this.formatImpact(`${head.qualifiedName} (${head.filePath}${line})`, impactOf(group))
  2304. );
  2305. }
  2306. return this.textResult(this.truncateOutput(sections.join('\n') + filterNote));
  2307. }
  2308. /**
  2309. * Describe a synthesized (dynamic-dispatch) edge for human output: how the
  2310. * callback was wired up — the bridge static parsing can't see. Returns null
  2311. * for ordinary static edges. Used by trace + the node trail so a synthesized
  2312. * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
  2313. */
  2314. private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
  2315. if (!edge || edge.provenance !== 'heuristic') return null;
  2316. const m = edge.metadata as Record<string, unknown> | undefined;
  2317. const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
  2318. const at = registeredAt ? ` @${registeredAt}` : '';
  2319. if (m?.synthesizedBy === 'callback') {
  2320. const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
  2321. const field = m.field ? ` on .${String(m.field)}` : '';
  2322. return {
  2323. label: `callback — registered via ${via}${field} (dynamic dispatch)`,
  2324. compact: `dynamic: callback via ${via}${at}`,
  2325. registeredAt,
  2326. };
  2327. }
  2328. if (m?.synthesizedBy === 'event-emitter') {
  2329. const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
  2330. return {
  2331. label: `event ${ev} — emit → handler (dynamic dispatch)`,
  2332. compact: `dynamic: event ${ev}${at}`,
  2333. registeredAt,
  2334. };
  2335. }
  2336. if (m?.synthesizedBy === 'react-render') {
  2337. return {
  2338. label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
  2339. compact: `dynamic: React re-render via setState${at}`,
  2340. registeredAt,
  2341. };
  2342. }
  2343. if (m?.synthesizedBy === 'jsx-render') {
  2344. const child = m.via ? `<${String(m.via)}>` : 'a child component';
  2345. return {
  2346. label: `renders ${child} (JSX child — dynamic dispatch)`,
  2347. compact: `dynamic: renders ${child}`,
  2348. registeredAt,
  2349. };
  2350. }
  2351. if (m?.synthesizedBy === 'vue-handler') {
  2352. const ev = m.event ? `@${String(m.event)}` : 'a template event';
  2353. return {
  2354. label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
  2355. compact: `dynamic: Vue ${ev} handler`,
  2356. registeredAt,
  2357. };
  2358. }
  2359. if (m?.synthesizedBy === 'interface-impl') {
  2360. return {
  2361. label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
  2362. compact: `dynamic: interface → impl${at}`,
  2363. registeredAt,
  2364. };
  2365. }
  2366. if (m?.synthesizedBy === 'closure-collection') {
  2367. const field = m.field ? `\`${String(m.field)}\`` : 'a collection';
  2368. return {
  2369. label: `closure collection — runs handlers appended to ${field} (dynamic dispatch)`,
  2370. compact: `dynamic: runs ${field} handlers${at}`,
  2371. registeredAt,
  2372. };
  2373. }
  2374. if (m?.synthesizedBy === 'fn-pointer-dispatch') {
  2375. const via = m.via ? `\`${String(m.via)}\`` : 'a function pointer';
  2376. return {
  2377. label: `function-pointer dispatch via ${via} (dynamic dispatch)`,
  2378. compact: `dynamic: fn-pointer ${m.via ? String(m.via) : ''}${at}`,
  2379. registeredAt,
  2380. };
  2381. }
  2382. if (m?.synthesizedBy === 'goframe-route') {
  2383. const route = m.route ? `\`${String(m.route)}\`` : 'a route';
  2384. return {
  2385. label: `GoFrame route ${route} — reflective Bind → controller method (dynamic dispatch)`,
  2386. compact: `dynamic: GoFrame route ${m.route ? String(m.route) : ''}${at}`,
  2387. registeredAt,
  2388. };
  2389. }
  2390. // Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
  2391. // flutter-build, …): a synthesized hop must never read as a bare static `calls`.
  2392. // It's a dynamic-dispatch bridge — label it as one and keep its wiring site.
  2393. if (typeof m?.synthesizedBy === 'string') {
  2394. const kind = m.synthesizedBy.replace(/-/g, ' ');
  2395. return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt };
  2396. }
  2397. return null;
  2398. }
  2399. /**
  2400. * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
  2401. * symbol names that usually spans the flow it's investigating (e.g.
  2402. * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
  2403. * Surface the longest call chain AMONG those named symbols — scoped to what the
  2404. * agent explicitly named, so (unlike a fuzzy relevance set) there's no
  2405. * wrong-feature wandering. Rides synthesized edges, so controller→service-
  2406. * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
  2407. *
  2408. * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
  2409. * CO-NAMING: the agent names the class too, so we keep only `list` candidates
  2410. * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
  2411. * dropping unrelated `OmsOrderService::list`.
  2412. */
  2413. private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set<string>; namedNodeIds: Set<string>; uniqueNamedNodeIds: Set<string>; spineCallSites: Map<string, number> } {
  2414. // spineCallSites: for each spine node, the line where it CALLS the next hop —
  2415. // lets the source assembler window an oversize spine method (e.g. n8n's 962-line
  2416. // processRunExecutionData) to the call site instead of dumping the whole body.
  2417. const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>(), spineCallSites: new Map<string, number>() };
  2418. try {
  2419. const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
  2420. // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
  2421. // names (Class.method / Class::method) — the agent's most precise input,
  2422. // resolved exactly by findAllSymbols. (The old strip mangled Class.method
  2423. // into Class, throwing the method away.)
  2424. 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|astro|erl|hrl)$/i;
  2425. const tokens = [...new Set(
  2426. query.split(/[\s,()[\]]+/)
  2427. .map((t) => t.replace(FILE_EXT, '').trim())
  2428. .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
  2429. )].slice(0, 16);
  2430. if (tokens.length < 2) return EMPTY;
  2431. // Pool of name SEGMENTS (Class + method from every token) used to
  2432. // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
  2433. // CONTAINER class is itself named in the query.
  2434. const segPool = new Set<string>();
  2435. for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
  2436. const named = new Map<string, Node>();
  2437. // Nodes whose token is SPECIFIC — a (near-)unique callable name (<=3 defs in
  2438. // the whole graph). These are safe to SPARE a file on: the agent named THIS
  2439. // method (`getResponseWithInterceptorChain`, 1 def). A hyper-polymorphic name
  2440. // (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
  2441. // so naming it doesn't keep every backend variant full and flood the budget.
  2442. const uniqueNamedNodeIds = new Set<string>();
  2443. // token → resolved node ids: drives the token-coverage check that gates
  2444. // the dynamic-boundary scan (a token is covered when ANY of its nodes
  2445. // lands on the main chain — overloads off the chain don't count against).
  2446. const tokenNodes = new Map<string, string[]>();
  2447. // token → its full same-name callable family (before the container filter).
  2448. // A LARGE family that fails to connect on the chain is a polymorphic
  2449. // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
  2450. const tokenFamily = new Map<string, Node[]>();
  2451. // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
  2452. // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
  2453. // is constant→constant — the CALLABLE-only `named` set can't hold it, and
  2454. // without this the hop is invisible to the Flow path at every tier (the
  2455. // Relationships section catches it only on repos ≥500 files). Kept SEPARATE
  2456. // from `named` (which drives the call-chain + source sizing, callable-only);
  2457. // fed only to the dynamic-dispatch-links scan below.
  2458. const dynNamed = new Map<string, Node>();
  2459. const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
  2460. // Nodes resolved from a SHAPE-PRECISE token (camelCase / PascalCase /
  2461. // snake_case / qualified) — the same test the gather path uses. It is the
  2462. // difference between "the agent named this symbol" and "an ordinary English
  2463. // word in a prose question collided with a callable", and it is what makes
  2464. // the narrative-less return below safe (see `identityOnly`).
  2465. const isPreciseToken = (x: string) =>
  2466. /[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);
  2467. const preciseNamedIds = new Set<string>();
  2468. const hasHeuristicEdge = (id: string): boolean =>
  2469. [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
  2470. for (const t of tokens) {
  2471. const hits = this.findAllSymbols(cg, t).nodes;
  2472. const cands = hits.filter((n) => CALLABLE.has(n.kind));
  2473. tokenFamily.set(t, cands);
  2474. // A qualified or otherwise-specific name (<=3 hits) keeps all; an
  2475. // ambiguous simple name keeps only candidates whose container is named.
  2476. const specific = cands.length <= 3;
  2477. const pick = specific
  2478. ? cands
  2479. : cands.filter((n) => {
  2480. const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
  2481. const container = segs.length >= 2 ? segs[segs.length - 2] : '';
  2482. return !!container && segPool.has(container);
  2483. });
  2484. const kept = pick.slice(0, 6);
  2485. tokenNodes.set(t, kept.map((n) => n.id));
  2486. const precise = isPreciseToken(t);
  2487. for (const n of kept) {
  2488. named.set(n.id, n);
  2489. if (specific) uniqueNamedNodeIds.add(n.id);
  2490. if (precise) preciseNamedIds.add(n.id);
  2491. }
  2492. // Same token, non-callable synth endpoints (capped, precision-gated on an
  2493. // actual heuristic edge so plain config constants never qualify).
  2494. // Per-token sub-cap so one token's many endpoints (10 nix option writes
  2495. // of `programs.git.enable` across test configs) can't fill the pool
  2496. // before later tokens (`home.file`) get a slot.
  2497. if (dynNamed.size < 12) {
  2498. let tokenDyn = 0;
  2499. for (const n of hits) {
  2500. if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
  2501. if (hasHeuristicEdge(n.id)) {
  2502. dynNamed.set(n.id, n);
  2503. if (precise) preciseNamedIds.add(n.id);
  2504. tokenDyn++;
  2505. }
  2506. if (dynNamed.size >= 12 || tokenDyn >= 4) break;
  2507. }
  2508. }
  2509. if (named.size > 40) break;
  2510. }
  2511. // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
  2512. // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
  2513. // already shown in the rendered main chain (a 2-node chain renders nothing, so a
  2514. // direct named→named synth hop still surfaces — #687).
  2515. const collectSynthLinks = (skipInChain: ((e: Edge) => boolean) | null): string[] => {
  2516. const synthLines: string[] = [];
  2517. const synthSeen = new Set<string>();
  2518. for (const n of [...named.values(), ...dynNamed.values()]) {
  2519. if (synthLines.length >= 6) break;
  2520. for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
  2521. if (synthLines.length >= 6) break;
  2522. if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
  2523. if (skipInChain && skipInChain(edge)) continue;
  2524. const src = edge.source === n.id ? n : other;
  2525. const tgt = edge.source === n.id ? other : n;
  2526. const key = `${src.name}>${tgt.name}`;
  2527. if (synthSeen.has(key)) continue;
  2528. synthSeen.add(key);
  2529. const note = this.synthEdgeNote(edge);
  2530. synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
  2531. }
  2532. }
  2533. return synthLines;
  2534. };
  2535. /**
  2536. * No narrative to print — but the agent still NAMED symbols, and their
  2537. * identity is a separate output from the prose (CG-38).
  2538. *
  2539. * `namedNodeIds` is not decoration: downstream it injects the named def into
  2540. * the file's cluster ranges and ranks it importance 9, which is the whole
  2541. * mechanism behind "a symbol the agent named renders" (the assembler's
  2542. * named-def injection). Returning EMPTY here threw that away whenever the
  2543. * named symbols happened not to form a call chain — two sibling closures in
  2544. * one factory (`queueMessage` / `flushQueuedMessages`, neither calling the
  2545. * other) produce no chain, no synth hop and no dispatch boundary, so BOTH
  2546. * defs lost importance 9 and the file rendered from its head instead: the
  2547. * agent got the `QueuedMessage` interface at L70 and had to Read the file
  2548. * for the functions at L1087/L1102 it had asked for by name.
  2549. *
  2550. * Restricted to SHAPE-PRECISE tokens. With a narrative present the prose is
  2551. * itself corroboration that the resolution was right, so that path keeps
  2552. * every named id as before; with nothing corroborating it, only an
  2553. * unambiguous symbol reference may promote — an English word in a prose
  2554. * question that happens to exact-match a callable must not earn importance 9.
  2555. * Same distinction, same test, as the gather path's `isPreciseToken`.
  2556. */
  2557. const identityOnly = () => (preciseNamedIds.size === 0 ? EMPTY : {
  2558. text: '',
  2559. pathNodeIds: new Set<string>(),
  2560. namedNodeIds: new Set<string>(preciseNamedIds),
  2561. uniqueNamedNodeIds: new Set<string>([...uniqueNamedNodeIds].filter((id) => preciseNamedIds.has(id))),
  2562. spineCallSites: new Map<string, number>(),
  2563. });
  2564. if (named.size < 2) {
  2565. // <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized
  2566. // edges among named CONSTANT/VARIABLE endpoints — RTK thunk→thunk is
  2567. // constant→constant, so `named` can be empty while `dynNamed` holds the
  2568. // whole chain; (2) the one resolved callable's body may hold the
  2569. // dynamic-dispatch site that EXPLAINS a half-connected flow.
  2570. const synthLines = collectSynthLinks(null);
  2571. const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || '');
  2572. if (synthLines.length === 0 && !boundaries) return identityOnly();
  2573. const out: string[] = [];
  2574. if (synthLines.length) out.push(
  2575. '**Dynamic-dispatch links among your symbols**',
  2576. '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
  2577. '', ...synthLines, '');
  2578. if (boundaries) out.push(boundaries);
  2579. out.push('> Full source for these symbols is below.\n');
  2580. return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
  2581. }
  2582. const MAX_HOPS = 7;
  2583. let best: Array<{ node: Node; edge: Edge | null }> | null = null;
  2584. // BFS the full call graph (incl. synth edges) from each named seed, but
  2585. // only ACCEPT a sink that is also named — both ends anchored to symbols the
  2586. // agent named, so the chain stays on-topic while bridging intermediates
  2587. // (e.g. the exact interface overload) that the token resolution missed.
  2588. for (const seed of [...named.values()].slice(0, 8)) {
  2589. const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
  2590. parent.set(seed.id, { prev: null, edge: null, node: seed });
  2591. const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
  2592. let deep: string | null = null, deepDepth = 0;
  2593. const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
  2594. for (let h = 0; h < q.length && parent.size < 1500; h++) {
  2595. const { id, depth, streak } = q[h]!;
  2596. if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
  2597. if (depth >= MAX_HOPS - 1) continue;
  2598. for (const c of cg.getCallees(id)) {
  2599. if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
  2600. const newStreak = named.has(c.node.id) ? 0 : streak + 1;
  2601. if (newStreak > MAX_BRIDGE) continue;
  2602. parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
  2603. q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
  2604. }
  2605. }
  2606. if (!deep) continue;
  2607. const chain: Array<{ node: Node; edge: Edge | null }> = [];
  2608. let cur: string | null = deep;
  2609. while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
  2610. chain.reverse();
  2611. if (!best || chain.length > best.length) best = chain;
  2612. }
  2613. const hasMain = !!best && best.length >= 3;
  2614. const pathIds = new Set((best ?? []).map((s) => s.node.id));
  2615. // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
  2616. // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
  2617. // the assembler window an oversize spine method to the call instead of dumping it.
  2618. const spineCallSites = new Map<string, number>();
  2619. if (best) for (let i = 0; i < best.length - 1; i++) {
  2620. const ln = best[i + 1]?.edge?.line;
  2621. if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln);
  2622. }
  2623. // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
  2624. // asked about did not fully connect: some token resolved to nodes but
  2625. // none of them sit on the main chain (or there is no chain at all). A
  2626. // healthy flow skips this entirely. Scan order: the chain's dead end
  2627. // first (where the partial flow stops), then the disconnected symbols,
  2628. // agent-specific (unique-named) ones first.
  2629. let boundaryText = '';
  2630. {
  2631. const uncovered: Node[] = [];
  2632. if (!hasMain) {
  2633. // No rendered chain — but a 2-node chain still CONNECTS its two
  2634. // endpoints (e.g. via one synthesized hop, surfaced below as a
  2635. // dynamic-dispatch link). Only nodes off that short chain are
  2636. // unexplained breaks worth scanning.
  2637. for (const n of named.values()) if (!pathIds.has(n.id)) uncovered.push(n);
  2638. } else {
  2639. for (const ids of tokenNodes.values()) {
  2640. if (ids.length === 0 || ids.some((id) => pathIds.has(id))) continue;
  2641. for (const id of ids) { const n = named.get(id); if (n) uncovered.push(n); }
  2642. }
  2643. }
  2644. if (uncovered.length > 0) {
  2645. const scanList: Node[] = [];
  2646. if (hasMain) scanList.push(best![best!.length - 1]!.node);
  2647. scanList.push(...uncovered.sort((a, b) =>
  2648. (uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (uniqueNamedNodeIds.has(a.id) ? 1 : 0)));
  2649. boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
  2650. }
  2651. }
  2652. // Interface/registry-dispatch announcement (extends #687 to GRAPH-visible
  2653. // polymorphism). A method the agent NAMED that resolves to a large same-name
  2654. // family AND did not land on the main chain is almost always a runtime
  2655. // dispatch (plugin/strategy/handler interface): the concrete target is chosen
  2656. // at runtime from N implementations, so no single static edge is the answer.
  2657. // The body-scan above can't see this — `nodeType.execute()` is textually an
  2658. // ordinary call; the polymorphism lives in the graph (implements edges), so
  2659. // detect it there. Fires ONLY for an uncovered named token; a connected flow
  2660. // stays silent.
  2661. let polyText = '';
  2662. {
  2663. const POLY_MIN_FAMILY = 8; // smaller families are overload sets, not dispatch
  2664. const polyCands: Array<{ token: string; family: Node[] }> = [];
  2665. for (const [t, fam] of tokenFamily) {
  2666. if (fam.length < POLY_MIN_FAMILY) continue;
  2667. const ids = tokenNodes.get(t) || [];
  2668. if (ids.some((id) => pathIds.has(id))) continue; // covered by the flow — silent
  2669. polyCands.push({ token: t, family: fam });
  2670. }
  2671. if (polyCands.length) polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
  2672. }
  2673. // Supplementary: dynamic-dispatch (synthesized) edges incident to a named
  2674. // symbol (incl. the non-callable CONSTANT endpoints in `dynNamed`) — the
  2675. // indirect hops an agent would otherwise grep/Read to reconstruct ("where do
  2676. // the appended `validators` actually run?"). Surfaced even when the OTHER end
  2677. // wasn't named. The skip drops a hop already in the rendered main chain; a
  2678. // 2-node chain renders nothing (hasMain false) so a direct named→named synth
  2679. // hop still surfaces — too short for Flow, but #687-visible here.
  2680. const synthLines = collectSynthLinks(
  2681. hasMain ? (e: Edge) => pathIds.has(e.source) && pathIds.has(e.target) : null
  2682. );
  2683. if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return identityOnly();
  2684. const out: string[] = [];
  2685. if (hasMain) {
  2686. out.push('**Flow (call path among the symbols you queried)**', '');
  2687. for (let i = 0; i < best!.length; i++) {
  2688. const step = best![i]!;
  2689. if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`); }
  2690. out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
  2691. }
  2692. out.push('');
  2693. }
  2694. if (synthLines.length) {
  2695. out.push(
  2696. '**Dynamic-dispatch links among your symbols**',
  2697. '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
  2698. '',
  2699. ...synthLines,
  2700. ''
  2701. );
  2702. }
  2703. if (boundaryText) out.push(boundaryText);
  2704. if (polyText) out.push(polyText);
  2705. out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
  2706. // namedNodeIds = every callable the agent explicitly named (a superset of
  2707. // the spine). A file holding one is something the agent asked to SEE, so it
  2708. // must keep full source even if it's an off-spine polymorphic sibling — the
  2709. // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
  2710. // as the mechanism, not as an interchangeable leaf. See the skeleton gate.
  2711. return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites };
  2712. } catch {
  2713. return EMPTY;
  2714. }
  2715. }
  2716. /**
  2717. * Dynamic-boundary surfacing (#687): when the flow among the agent's named
  2718. * symbols does not fully connect, scan the disconnected symbols' bodies for
  2719. * dynamic-dispatch sites (computed member calls, getattr, reflection, typed
  2720. * message buses, runtime-keyed emits) and ANNOUNCE the boundary — the exact
  2721. * site, the form, and (when a key is statically visible) candidate targets —
  2722. * instead of guessing edges. The answer to "how does A reach B" when no
  2723. * static path exists IS the dispatch site: that's where the flow continues
  2724. * at runtime. Query-time, deterministic, zero graph mutation; a fully
  2725. * connected flow never reaches this method.
  2726. */
  2727. private buildDynamicBoundaries(cg: CodeGraph, scanList: Node[], named: Map<string, Node>): string {
  2728. const MAX_NOTES = 4; // boundary bullets per explore
  2729. const MAX_SCAN = 8; // bodies scanned
  2730. const MAX_TOTAL_CHARS = 200_000;
  2731. let projectRoot: string;
  2732. try { projectRoot = cg.getProjectRoot(); } catch { return ''; }
  2733. const notes: string[] = [];
  2734. const seenNode = new Set<string>();
  2735. const seenSite = new Set<string>();
  2736. let scanned = 0, charsScanned = 0;
  2737. for (const node of scanList) {
  2738. if (notes.length >= MAX_NOTES || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS) break;
  2739. if (seenNode.has(node.id) || !node.startLine || !node.endLine) continue;
  2740. seenNode.add(node.id);
  2741. const absPath = validatePathWithinRoot(projectRoot, node.filePath);
  2742. if (!absPath || !existsSync(absPath)) continue;
  2743. let content: string;
  2744. try { content = readFileSync(absPath, 'utf-8'); } catch { continue; }
  2745. const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
  2746. scanned++;
  2747. charsScanned += body.length;
  2748. for (const m of scanDynamicDispatch(body, node.language || '', node.startLine)) {
  2749. if (notes.length >= MAX_NOTES) break;
  2750. const siteKey = `${node.filePath}:${m.line}:${m.form}`;
  2751. if (seenSite.has(siteKey)) continue;
  2752. seenSite.add(siteKey);
  2753. const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
  2754. notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
  2755. if (m.key) {
  2756. const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
  2757. if (cand) notes.push(` ${cand}`);
  2758. }
  2759. }
  2760. }
  2761. if (notes.length === 0) return '';
  2762. return [
  2763. '**Dynamic boundaries (the static path ends at runtime dispatch)**',
  2764. '',
  2765. ...notes,
  2766. '',
  2767. '> These sites choose their call target at runtime (registry / bus / reflection) — the site shown IS where the flow continues. To follow it, run codegraph_explore or codegraph_node on a candidate; source for the sites above is included below.',
  2768. '',
  2769. ].join('\n');
  2770. }
  2771. /**
  2772. * Interface/registry-dispatch announcement — #687 extended to GRAPH-visible
  2773. * polymorphism (the body-scan can't see it: `nodeType.execute()` is textually
  2774. * an ordinary call; the polymorphism lives in the `implements`/`extends` edges).
  2775. *
  2776. * A method the agent named that resolves to a large same-name family whose
  2777. * definers overwhelmingly implement/extend ONE supertype is a runtime dispatch:
  2778. * the concrete target is chosen at runtime from N implementations, so no single
  2779. * static edge is "the answer" — the implementations ARE the continuations. We
  2780. * announce the supertype, its TRUE implementer count, and a few concrete targets,
  2781. * then steer to codegraph_explore. Graph-only, query-time, zero mutation; the
  2782. * caller fires it ONLY for an UNCOVERED named token, so a connected flow is silent.
  2783. *
  2784. * Robust to FTS sampling bias: the same-name family is a capped FTS sample that
  2785. * over-represents whatever FTS ranks first (n8n: DB `TableOperation.execute`
  2786. * outnumbered `INodeType.execute` in the sample 7:6 even though INodeType has
  2787. * 611 implementers vs a handful). So candidate supertypes are ranked by their
  2788. * TRUE graph-wide implementer count, NOT their frequency in the sample.
  2789. */
  2790. private buildPolymorphicBoundaries(cg: CodeGraph, candidates: Array<{ token: string; family: Node[] }>, named: Map<string, Node>): string {
  2791. const CLASSY = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'abstract']);
  2792. const MIN_IMPL = 8; // a supertype needs >= this many implementers to count as "polymorphic"
  2793. const MIN_SUPPORT = 2; // >= this many sampled definers must share the supertype (ties it to the token)
  2794. const SAMPLE = 40; // family members inspected per token
  2795. const MAX_NOTES = 3;
  2796. const rel = (p: string) => p.replace(/\\/g, '/');
  2797. const containerOf = (m: Node): Node | null => {
  2798. try { const ce = cg.getIncomingEdges(m.id).find((e) => e.kind === 'contains'); return ce ? cg.getNode(ce.source) : null; }
  2799. catch { return null; }
  2800. };
  2801. const notes: string[] = [];
  2802. const seenSuper = new Set<string>();
  2803. for (const { token, family } of candidates) {
  2804. if (notes.length >= MAX_NOTES) break;
  2805. // supertype id → how many sampled definers share it + a few example definers
  2806. const supers = new Map<string, { node: Node; count: number; targets: Node[] }>();
  2807. for (const m of family.slice(0, SAMPLE)) {
  2808. const container = containerOf(m);
  2809. if (!container || !CLASSY.has(container.kind)) continue;
  2810. let sups: Node[] = [];
  2811. try {
  2812. sups = cg.getOutgoingEdges(container.id)
  2813. .filter((e) => e.kind === 'implements' || e.kind === 'extends')
  2814. .map((e) => { try { return cg.getNode(e.target); } catch { return null; } })
  2815. .filter((n): n is Node => !!n && CLASSY.has(n.kind) && (n.name?.length || 0) >= 3);
  2816. } catch { /* no supertypes — free function or unresolved */ }
  2817. for (const s of sups) {
  2818. const e = supers.get(s.id) || { node: s, count: 0, targets: [] };
  2819. e.count++;
  2820. if (e.targets.length < 6) e.targets.push(m);
  2821. supers.set(s.id, e);
  2822. }
  2823. }
  2824. // Pick the supertype with the most TRUE implementers (graph-wide), among
  2825. // those genuinely shared by the token's definers.
  2826. let best: { node: Node; impl: number; targets: Node[] } | null = null;
  2827. for (const { node, count, targets } of supers.values()) {
  2828. if (count < MIN_SUPPORT) continue;
  2829. let impl = 0;
  2830. try { impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length; }
  2831. catch { /* leave 0 — gated out below */ }
  2832. if (impl < MIN_IMPL) continue;
  2833. if (!best || impl > best.impl) best = { node, impl, targets };
  2834. }
  2835. if (!best || seenSuper.has(best.node.id)) continue;
  2836. seenSuper.add(best.node.id);
  2837. const namedNames = new Set([...named.values()].map((n) => n.name));
  2838. const eg = best.targets.slice(0, 4).map((m) => {
  2839. const cont = containerOf(m);
  2840. const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
  2841. const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
  2842. return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
  2843. });
  2844. const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
  2845. notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
  2846. }
  2847. if (notes.length === 0) return '';
  2848. return [
  2849. '**Interface dispatch (a named method has many implementations)**',
  2850. '',
  2851. ...notes,
  2852. '',
  2853. '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run codegraph_explore on a listed target.',
  2854. '',
  2855. ].join('\n');
  2856. }
  2857. /**
  2858. * Shortlist candidate runtime targets for a dispatch key surfaced by
  2859. * {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
  2860. * `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
  2861. * normalized-containment post-filter (FTS camel-splitting is fuzzier than a
  2862. * candidate list should be). Symbols the agent already named sort first and
  2863. * are marked — that's the "you were right, here's the wiring" case.
  2864. */
  2865. private boundaryCandidates(cg: CodeGraph, key: string, keyIsType: boolean, named: Map<string, Node>, selfId: string): string {
  2866. const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
  2867. const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
  2868. const keyNorm = norm(key);
  2869. if (keyNorm.length < 3) return '';
  2870. const cands = new Map<string, Node>();
  2871. const consider = (n: Node | undefined | null) => {
  2872. if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id)) return;
  2873. const nameNorm = norm(n.name || '');
  2874. if (nameNorm.length < 3) return;
  2875. if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm)) return;
  2876. cands.set(n.id, n);
  2877. };
  2878. const cap = key.charAt(0).toUpperCase() + key.slice(1);
  2879. const probes = keyIsType
  2880. ? [`${key}Handler`, key]
  2881. : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
  2882. for (const p of probes) {
  2883. try { for (const n of cg.getNodesByName(p)) consider(n); } catch { /* exact probe miss is fine */ }
  2884. }
  2885. let raw = 0;
  2886. try {
  2887. const results = cg.searchNodes(key, { limit: 12 });
  2888. raw = results.length;
  2889. for (const r of results) consider(r.node);
  2890. } catch { /* FTS syntax edge — exact probes already ran */ }
  2891. if (cands.size === 0) {
  2892. return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
  2893. }
  2894. // A constructor candidate duplicates its class: extractors emit ctors as
  2895. // METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
  2896. const all = [...cands.values()];
  2897. const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
  2898. const namedNames = new Set([...named.values()].map((n) => n.name));
  2899. const isNamed = (n: Node) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
  2900. const list = all
  2901. .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
  2902. .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
  2903. .slice(0, 4)
  2904. .map((n) => {
  2905. // Typed-bus convention: the runtime target is the candidate class's
  2906. // Handle/Execute/Consume method — name the exact node, not just the class.
  2907. let display = n.qualifiedName || n.name;
  2908. let at = `${n.filePath}:${n.startLine}`;
  2909. if (keyIsType && n.kind === 'class') {
  2910. try {
  2911. const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
  2912. const method = cg.getOutgoingEdges(n.id)
  2913. .filter((e) => e.kind === 'contains')
  2914. .map((e) => { try { return cg.getNode(e.target); } catch { return null; } })
  2915. .find((c): c is Node => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
  2916. if (method) { display = `${n.name}.${method.name}`; at = `${method.filePath}:${method.startLine}`; }
  2917. } catch { /* class without resolvable members — show the class itself */ }
  2918. }
  2919. return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
  2920. });
  2921. return `candidates for key \`${key}\`: ${list.join(', ')}`;
  2922. }
  2923. /**
  2924. * Compact "blast radius" for the entry symbols of an explore result: who
  2925. * depends on each (callers) and which test files cover it — LOCATIONS ONLY,
  2926. * no source, so the agent knows what to update / re-verify before editing
  2927. * without reaching for a separate impact call. Always-on, but skips symbols
  2928. * that have no dependents (nothing to warn about), and returns '' when none
  2929. * qualify so a leaf-only exploration stays clean.
  2930. */
  2931. private buildBlastRadiusSection(cg: CodeGraph, subgraph: Subgraph): string {
  2932. const ROOT_CAP = 5; // only the symbols the query actually targeted
  2933. const FILE_CAP = 4; // caller files listed per symbol before "+N more"
  2934. const MEANINGFUL = new Set<string>([
  2935. 'function', 'method', 'class', 'interface', 'struct', 'union', 'trait', 'protocol',
  2936. 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
  2937. ]);
  2938. const rel = (p: string) => p.replace(/\\/g, '/');
  2939. const roots = subgraph.roots
  2940. .map((id) => subgraph.nodes.get(id))
  2941. .filter((n): n is Node => !!n && MEANINGFUL.has(n.kind))
  2942. .slice(0, ROOT_CAP);
  2943. if (roots.length === 0) return '';
  2944. const entries: string[] = [];
  2945. for (const root of roots) {
  2946. let callers: Array<{ node: Node }> = [];
  2947. try { callers = cg.getCallers(root.id) as Array<{ node: Node }>; } catch { /* skip this root */ }
  2948. const seen = new Set<string>();
  2949. const uniq: Node[] = [];
  2950. for (const c of callers) {
  2951. if (c?.node && !seen.has(c.node.id)) { seen.add(c.node.id); uniq.push(c.node); }
  2952. }
  2953. if (uniq.length === 0) continue; // no blast radius → nothing to flag
  2954. const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
  2955. const testFiles = callerFiles.filter((f) => isTestFile(f));
  2956. const nonTest = callerFiles.filter((f) => !isTestFile(f));
  2957. const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
  2958. const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
  2959. const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
  2960. const tests = testFiles.length > 0
  2961. ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
  2962. : this.indirectTestNote(cg, uniq, rel);
  2963. entries.push(
  2964. `- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`,
  2965. );
  2966. }
  2967. if (entries.length === 0) return '';
  2968. return [
  2969. '**Blast radius — what depends on these (update/verify before editing)**',
  2970. '',
  2971. ...entries,
  2972. '',
  2973. ].join('\n');
  2974. }
  2975. /**
  2976. * Test-coverage note for a blast-radius entry whose DIRECT callers include no
  2977. * test file. A helper called only by production code can still be exercised
  2978. * by tests further up the caller chain (#1475: 40% of directly-unflagged
  2979. * symbols had a test within 2-3 hops), so walk up to 2 more hops before
  2980. * claiming anything — and even then claim only what was measured.
  2981. */
  2982. private indirectTestNote(cg: CodeGraph, directCallers: Node[], rel: (p: string) => string): string {
  2983. const MAX_HOPS = 3; // direct callers are hop 1
  2984. const BUDGET = 64; // getCallers lookups per entry — bounds god-fan-in symbols
  2985. const FILE_CAP = 2;
  2986. let budget = BUDGET;
  2987. const visited = new Set(directCallers.map((n) => n.id));
  2988. let frontier = directCallers;
  2989. for (let hop = 2; hop <= MAX_HOPS && frontier.length > 0 && budget > 0; hop++) {
  2990. const next: Node[] = [];
  2991. const found = new Set<string>();
  2992. for (const node of frontier) {
  2993. if (budget-- <= 0) break;
  2994. let callers: Array<{ node: Node }> = [];
  2995. try { callers = cg.getCallers(node.id) as Array<{ node: Node }>; } catch { continue; }
  2996. for (const c of callers) {
  2997. const n = c?.node;
  2998. if (!n || visited.has(n.id)) continue;
  2999. visited.add(n.id);
  3000. const f = rel(n.filePath);
  3001. if (isTestFile(f)) found.add(f);
  3002. else next.push(n);
  3003. }
  3004. }
  3005. if (found.size > 0) {
  3006. const files = [...found];
  3007. const shown = files.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
  3008. const more = files.length > FILE_CAP ? ` +${files.length - FILE_CAP}` : '';
  3009. return `; tested via callers: ${shown}${more}`;
  3010. }
  3011. frontier = next;
  3012. }
  3013. // Budget exhaustion means hops 2-3 weren't fully searched — fall back to
  3014. // the weaker claim that IS established by the direct-caller check.
  3015. return budget > 0
  3016. ? `; no tests found within ${MAX_HOPS} caller hops`
  3017. : '; no test calls this directly';
  3018. }
  3019. /**
  3020. * Graph-connectivity relevance via Random-Walk-with-Restart (personalized
  3021. * PageRank) from the query's matched SEED nodes over the call/reference graph.
  3022. *
  3023. * This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
  3024. * codegraph's home turf: relevance by STRUCTURE, not words. A file whose
  3025. * symbols are call-connected to the matched cluster accrues walk mass and
  3026. * ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
  3027. * "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
  3028. * — gets only its own restart probability and ranks ~0. Immune to the
  3029. * tokenization trap that fools term matching, deterministic, no embeddings.
  3030. *
  3031. * Undirected adjacency (reachability both ways), restart α=0.25 to the seeds,
  3032. * power iteration to convergence. Bounded to the already-relevant subgraph, so
  3033. * it's a few hundred nodes × ~25 iterations — negligible cost.
  3034. */
  3035. private computeGraphRelevance(
  3036. nodeIds: string[],
  3037. edges: Edge[],
  3038. seedIds: Set<string>,
  3039. ): Map<string, number> {
  3040. const out = new Map<string, number>();
  3041. const n = nodeIds.length;
  3042. if (n === 0) return out;
  3043. const idx = new Map<string, number>();
  3044. for (let i = 0; i < n; i++) idx.set(nodeIds[i]!, i);
  3045. const RANK_EDGES = new Set<string>([
  3046. 'calls', 'references', 'extends', 'implements', 'overrides',
  3047. 'instantiates', 'returns', 'type_of', 'imports',
  3048. ]);
  3049. const adj: number[][] = Array.from({ length: n }, () => []);
  3050. for (const e of edges) {
  3051. if (!RANK_EDGES.has(e.kind)) continue;
  3052. const i = idx.get(e.source);
  3053. const j = idx.get(e.target);
  3054. if (i === undefined || j === undefined || i === j) continue;
  3055. adj[i]!.push(j);
  3056. adj[j]!.push(i); // undirected — reachable either direction
  3057. }
  3058. // Restart vector: uniform over seeds present in the candidate set. (Falls
  3059. // back to uniform-over-all if no seed landed in the set, so we never return
  3060. // all-zero.)
  3061. const r = new Array<number>(n).fill(0);
  3062. let rsum = 0;
  3063. for (const id of seedIds) {
  3064. const i = idx.get(id);
  3065. if (i !== undefined) { r[i] = 1; rsum += 1; }
  3066. }
  3067. if (rsum === 0) { for (let i = 0; i < n; i++) r[i] = 1; rsum = n; }
  3068. for (let i = 0; i < n; i++) r[i]! /= rsum;
  3069. const alpha = 0.25;
  3070. let s = r.slice();
  3071. for (let iter = 0; iter < 25; iter++) {
  3072. const next = new Array<number>(n).fill(0);
  3073. for (let i = 0; i < n; i++) {
  3074. const si = s[i]!;
  3075. if (si === 0) continue;
  3076. const d = adj[i]!.length;
  3077. if (d === 0) { next[i]! += si; continue; } // dangling: keep its mass
  3078. const share = si / d;
  3079. for (const j of adj[i]!) next[j]! += share;
  3080. }
  3081. for (let i = 0; i < n; i++) s[i] = (1 - alpha) * next[i]! + alpha * r[i]!;
  3082. }
  3083. for (let i = 0; i < n; i++) out.set(nodeIds[i]!, s[i]!);
  3084. return out;
  3085. }
  3086. /**
  3087. * Handle codegraph_explore — deep exploration in a single call
  3088. *
  3089. * Strategy: find relevant symbols via graph traversal, group by file,
  3090. * then read contiguous file sections covering all symbols per file.
  3091. * This replaces multiple codegraph_node + Read calls.
  3092. *
  3093. * Output size is adaptive to project file count via
  3094. * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
  3095. * tax on small projects while earning its keep on large ones.
  3096. */
  3097. private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
  3098. const rawQuery = this.validateString(args.query, 'query');
  3099. if (typeof rawQuery !== 'string') return rawQuery;
  3100. // One normalization point so the flow-builder, relevance search, and
  3101. // ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
  3102. const query = normalizeQuerySpelling(rawQuery);
  3103. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  3104. const projectRoot = cg.getProjectRoot();
  3105. // Resolve adaptive output budget from project size. Falls back to the
  3106. // largest-tier defaults if stats aren't available, which preserves
  3107. // pre-#185 behavior for callers that hit the rare stats failure.
  3108. let budget: ExploreOutputBudget;
  3109. let indexedFileCount = -1;
  3110. try {
  3111. indexedFileCount = cg.getStats().fileCount;
  3112. budget = getExploreOutputBudget(indexedFileCount);
  3113. } catch {
  3114. budget = getExploreOutputBudget(Infinity);
  3115. }
  3116. const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
  3117. // File paths named in the query become PINNED files: guaranteed admission,
  3118. // top of the rank order, funded first — and their span is REMOVED from the
  3119. // matching query. Runs on the RAW query (normalizeQuerySpelling strips
  3120. // `/digits` tails, which would mangle numeric path segments). Without this,
  3121. // a SvelteKit path like `runs/[runId]/+page.svelte` was shredded by the
  3122. // seeding tokenizer (splits on brackets → `runId` seeded as a "named
  3123. // symbol") and by FTS (`page`/`runs` fragments admitted every sibling
  3124. // `+page.svelte`), starving the very files the agent asked for.
  3125. let pinnedFiles: string[] = [];
  3126. let unresolvedPathSpans: string[] = [];
  3127. let matchQuery = query;
  3128. if (queryMightContainPaths(rawQuery)) {
  3129. try {
  3130. const extraction = extractQueryPaths(
  3131. rawQuery,
  3132. cg.getFiles().map((f) => f.path),
  3133. { maxPins: maxFiles },
  3134. );
  3135. if (extraction.pinnedFiles.length > 0 || extraction.unresolvedPathSpans.length > 0) {
  3136. pinnedFiles = extraction.pinnedFiles;
  3137. unresolvedPathSpans = extraction.unresolvedPathSpans;
  3138. matchQuery = normalizeQuerySpelling(extraction.strippedQuery);
  3139. }
  3140. } catch { /* path pinning must never fail an explore call */ }
  3141. }
  3142. const pinnedSet = new Set(pinnedFiles);
  3143. const pinnedOrder = new Map(pinnedFiles.map((p, i) => [p, i]));
  3144. // Per-file allocation diagnostic (CG-4). `null` unless CODEGRAPH_EXPLORE_DEBUG
  3145. // is set — every `diag?.` below is then a no-op and the response is
  3146. // byte-identical. It only OBSERVES: it must never feed back into rendering.
  3147. const diag = ExploreDiagnostics.start(query, projectRoot, budget, maxFiles, indexedFileCount);
  3148. // What this session has already been served for THIS project (CG-17), and
  3149. // whether this call may act on it (CG-18). Dedup is off on the session's
  3150. // first call by construction — there is nothing to point back AT — and off
  3151. // entirely under `CODEGRAPH_EXPLORE_DEDUP=0`.
  3152. const priorCalls = viewForProject(readExploreSessionView(args), projectRoot);
  3153. diag?.noteSession(priorCalls);
  3154. const dedupEnabled = exploreDedupEnabled() && (priorCalls?.calls.length ?? 0) > 0;
  3155. // Cross-call dedup accounting (CG-18). `newSourceChars` is the load-bearing
  3156. // one: a response whose source is ENTIRELY back-references is the shape that
  3157. // reads as a failure, so the loop keeps the top suppressed file's real
  3158. // section in hand and restores it if nothing new made it in — see
  3159. // `suppressedFallback` below.
  3160. let newSourceChars = 0;
  3161. const backReferencedFiles: string[] = [];
  3162. // What this call ends up emitting, per file — the record handed back to the
  3163. // session state on the main thread. Filled by every render path below, then
  3164. // filtered to the files that SURVIVE the final hard-ceiling cut, so the
  3165. // record is what the agent actually received rather than what the loop
  3166. // hoped to send.
  3167. //
  3168. // Back-referenced spans are recorded too, with zero bytes (CG-18): the
  3169. // record means "source the agent HOLDS for this file", not "bytes this call
  3170. // spent". Re-recording them refreshes them inside the retained-call window,
  3171. // so a file pointed at across many calls doesn't age out of the history and
  3172. // get re-served for no reason.
  3173. const emittedByFile = new Map<
  3174. string,
  3175. { ranges: ExploreLineRange[]; bytes: number; fingerprint?: string }
  3176. >();
  3177. const noteEmitted = (
  3178. fp: string,
  3179. ranges: ExploreLineRange[],
  3180. bytes: number,
  3181. fingerprint?: string,
  3182. ): void => {
  3183. const existing = emittedByFile.get(fp);
  3184. if (existing) {
  3185. existing.ranges.push(...ranges);
  3186. existing.bytes += bytes;
  3187. if (fingerprint) existing.fingerprint = fingerprint;
  3188. } else {
  3189. emittedByFile.set(fp, { ranges: [...ranges], bytes, fingerprint });
  3190. }
  3191. };
  3192. // Step 1: Find relevant context with generous parameters.
  3193. // Use a large maxNodes budget — explore has its own 35k char output limit
  3194. // that prevents context bloat, so more nodes just means better coverage
  3195. // across entry points (especially for large files like Svelte components).
  3196. // Matching runs on the path-stripped query; `query` stays for display.
  3197. const subgraph = await cg.findRelevantContext(matchQuery, {
  3198. searchLimit: 8,
  3199. traversalDepth: 3,
  3200. maxNodes: 200,
  3201. minScore: 0.2,
  3202. });
  3203. // Pinned files' symbols enter the gather unconditionally — the agent named
  3204. // the file itself, so its contents ARE the answer regardless of what the
  3205. // stripped query text matched (which, for a pure-path query, is nothing).
  3206. const PINNED_FILE_NODE_CAP = 300;
  3207. for (const fp of pinnedFiles) {
  3208. let fileNodes: Node[] = [];
  3209. try { fileNodes = cg.getNodesInFile(fp); } catch { continue; }
  3210. fileNodes
  3211. .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
  3212. .sort((a, b) => a.startLine - b.startLine)
  3213. .slice(0, PINNED_FILE_NODE_CAP)
  3214. .forEach((n) => { if (!subgraph.nodes.has(n.id)) subgraph.nodes.set(n.id, n); });
  3215. }
  3216. if (subgraph.nodes.size === 0) {
  3217. diag?.finishEmpty('no relevant code found — empty subgraph');
  3218. const missNote = unresolvedPathSpans.length > 0
  3219. ? ` (no indexed file uniquely matches ${unresolvedPathSpans.map((s) => `\`${s}\``).join(', ')})`
  3220. : '';
  3221. const empty = `No relevant code found for "${query}"${missNote}`;
  3222. // Still an explore call, so it is still recorded: an empty answer spends a
  3223. // call against the tier budget even though it emits no source.
  3224. return this.exploreResult(empty, {
  3225. projectRoot, query, files: [], sourceBytes: 0, responseBytes: empty.length,
  3226. });
  3227. }
  3228. // Graph-aware glue: findRelevantContext builds the subgraph from name/text
  3229. // search, so a method that BRIDGES named symbols — e.g. App.tsx's
  3230. // triggerRender, which calls the named triggerUpdate — is never a search hit
  3231. // and gets missed, forcing the agent to Read the file to trace it. Pull in
  3232. // the callers/callees of the entry (root) nodes, but ONLY those that live in
  3233. // files the subgraph already surfaces (where the agent reads to fill gaps),
  3234. // so we add wiring without dragging in unrelated files. These get an
  3235. // importance boost below so they survive the per-file cluster budget.
  3236. const glueNodeIds = new Set<string>();
  3237. const subgraphFiles = new Set<string>();
  3238. for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
  3239. const GLUE_NODE_CAP = 60;
  3240. for (const rootId of subgraph.roots) {
  3241. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  3242. let neighbors: Node[] = [];
  3243. try {
  3244. neighbors = [
  3245. ...cg.getCallers(rootId).map(c => c.node),
  3246. ...cg.getCallees(rootId).map(c => c.node),
  3247. ];
  3248. } catch {
  3249. continue;
  3250. }
  3251. for (const nb of neighbors) {
  3252. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  3253. if (subgraph.nodes.has(nb.id)) continue;
  3254. if (!subgraphFiles.has(nb.filePath)) continue;
  3255. subgraph.nodes.set(nb.id, nb);
  3256. glueNodeIds.add(nb.id);
  3257. }
  3258. }
  3259. // Named-symbol seeding: findRelevantContext is an FTS/text rank, so a query
  3260. // that's a BAG of symbol names skewed toward one phase (Alamofire: 5 build
  3261. // terms, each a high-frequency name, vs 3 validate terms) lets the
  3262. // lower-frequency names fall below the search cut — their definitions, and
  3263. // whole files (Validation.swift), never get gathered, so they can never
  3264. // render and the agent Reads them. Resolve EACH named token to its
  3265. // substantive definition (skip empty stubs + test files, same relevance the
  3266. // trace endpoint picker uses) and inject it as an entry, so every symbol the
  3267. // agent explicitly named is in the subgraph and its file is scored.
  3268. const namedSeedIds = new Set<string>();
  3269. // The subset of named seeds that earns the named-FIRST sort tier. We still
  3270. // SEED every ≤3-def name (so RWR / flow ranking is unchanged), but only the
  3271. // most-substantive def is tiered — a bare name's unrelated namesakes (Go's
  3272. // `NewClient` = real client + test fake + xds pool) must not fill the tier
  3273. // and crowd out the real answer file (grpc's `dialoptions.go`). Corroborated
  3274. // overloads (the query also named the type) all earn it. (#1064)
  3275. const tierSeedIds = new Set<string>();
  3276. // Files declaring a TYPE the query named by name — the counter-case guard
  3277. // for the declaration-only penalty (CG-28). Populated in the token loop.
  3278. const namedTypeFiles = new Set<string>();
  3279. {
  3280. 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|astro|erl|hrl)$/i;
  3281. const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
  3282. // Variables/constants seed too: in Svelte/React a `$state` variable
  3283. // (`chatAtBottom`, `feedAtBottom`) is exactly the kind of symbol an agent
  3284. // names in a query, and the exact-name search channel already returns
  3285. // them — only this seeding tier was callable-only. The NL-stopword guard
  3286. // below applies unchanged, so bare English words still can't seed a
  3287. // same-named local. Callables keep priority via the body-size sort.
  3288. const SEEDABLE = new Set([...CALLABLE, 'variable', 'constant']);
  3289. const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
  3290. const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
  3291. const callerCount = (n: Node) => { try { return cg.getCallers(n.id).length; } catch { return 0; } };
  3292. const tokens = [...new Set(
  3293. matchQuery.split(/[\s,()[\]]+/)
  3294. .map((t) => t.replace(FILE_EXT, '').trim())
  3295. .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
  3296. )].slice(0, 16);
  3297. // PascalCase tokens in the query are type/file disambiguators — when the
  3298. // agent writes "DataRequest task validate", the `task`/`validate` it wants
  3299. // are DataRequest's, NOT the same-named overloads in Validation.swift /
  3300. // Concurrency.swift / the abstract base. Used below to bias overloaded
  3301. // names toward the file/class the query also names. EXCLUDE the project
  3302. // name (a PascalCase token a user naturally includes) — it names the whole
  3303. // repo, so biasing toward it just pulls overloads to whichever stack
  3304. // embeds it, re-burying the rest (#720).
  3305. const projectNameTokens = cg.getProjectNameTokens();
  3306. const typeTokens = tokens.filter(
  3307. (o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o) && !projectNameTokens.has(normalizeNameToken(o)),
  3308. );
  3309. const inNamedContext = (n: Node) =>
  3310. typeTokens.some((ct) => {
  3311. const lc = ct.toLowerCase();
  3312. return n.filePath.toLowerCase().includes(lc) || n.qualifiedName.toLowerCase().includes(lc);
  3313. });
  3314. // NL-stopword guard: this seeding treats every token as "a symbol the
  3315. // agent named", but explore also takes natural-language questions, whose
  3316. // ordinary English words collide with real callables — "…check the latest
  3317. // version…" exact-matched a lone `check()` method, which then earned the
  3318. // named-FIRST sort tier and displaced the corroborated answer files from
  3319. // the whole render budget (the agent fell back to Read). A shape-precise
  3320. // token (camelCase, PascalCase, snake_case, qualified) is an unambiguous
  3321. // symbol reference and seeds unconditionally; a BARE lowercase word seeds
  3322. // only where the query corroborates the file — another query token is
  3323. // itself a symbol defined in that same file (the "check drain fire"
  3324. // sibling-bag case), which an incidental English-word collision never is.
  3325. const lcTokens = new Set(tokens.map((x) => x.toLowerCase()));
  3326. const isPreciseToken = (x: string) =>
  3327. /[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);
  3328. const fileNameSets = new Map<string, Set<string>>();
  3329. const coNamedInFile = (t: string, fp: string): boolean => {
  3330. let names = fileNameSets.get(fp);
  3331. if (!names) {
  3332. names = new Set<string>();
  3333. try {
  3334. for (const n of cg.getNodesInFile(fp)) names.add(n.name.toLowerCase());
  3335. } catch { /* unreadable file entry — treat as uncorroborated */ }
  3336. fileNameSets.set(fp, names);
  3337. }
  3338. const self = t.toLowerCase();
  3339. for (const o of lcTokens) {
  3340. if (o !== self && names.has(o)) return true;
  3341. }
  3342. return false;
  3343. };
  3344. for (const t of tokens) {
  3345. // Enumerate ALL defs of a bare token via the direct index, not FTS — a
  3346. // 50+-overload name (tokio `poll`) ranks the wanted def (`Harness::poll`)
  3347. // below the FTS cut, so findAllSymbols would never see it and the
  3348. // type-token bias below couldn't pick the harness.rs one. (Same fix as
  3349. // codegraph_node's findSymbolMatches.) Qualified tokens keep findAllSymbols.
  3350. const isQual = /[.\/]|::/.test(t);
  3351. const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
  3352. // A query that NAMES a declared type is a question ABOUT that type, and
  3353. // must still reach its declaration file at full weight — so record the
  3354. // files those declarations live in and exempt them from the
  3355. // declaration-only penalty below (CG-28). Only PRECISE tokens count, by
  3356. // the same NL-stopword reasoning as the seeding above: "…the file body…"
  3357. // must not exempt a `Body` interface it never meant to name. Kept
  3358. // separate from `namedSeedIds`, which is callable-only by construction —
  3359. // a type never becomes a named seed, so it cannot be the guard here.
  3360. if (isPreciseToken(t)) {
  3361. for (const n of raw) {
  3362. if (DECLARATION_KINDS.has(n.kind) && n.name.toLowerCase() === t.toLowerCase()) {
  3363. namedTypeFiles.add(n.filePath);
  3364. }
  3365. }
  3366. }
  3367. let cands = raw
  3368. .filter((n) => SEEDABLE.has(n.kind) && !isTestPath(n.filePath))
  3369. .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
  3370. // Field-name seeding fallback (#1196): a camelCase token that names NO
  3371. // definition of its own is usually an object-literal key / API field
  3372. // (`profileInfo`) — no node exists, so it contributed zero seeds and
  3373. // the files that DEFINE it (`getProfileInfoV2` in profileController)
  3374. // never surfaced. Seed its camel-infix definers instead: seedable
  3375. // symbols (callables + variables — `atBottom` must reach the `$state`
  3376. // variables `feedAtBottom`/`chatAtBottom`) whose name contains the
  3377. // token at a hump boundary or as a prefix.
  3378. // Exact-empty + camel-shaped only (bare words keep the NL-stopword
  3379. // guard below), shortest-first, capped so a hot infix can't flood.
  3380. if (cands.length === 0 && !isQual && /[a-z][A-Z]/.test(t)) {
  3381. const lcToken = t.toLowerCase();
  3382. cands = cg
  3383. .getNodesByNameSubstring(t, {
  3384. kinds: ['function', 'method', 'component', 'variable', 'constant'],
  3385. limit: 60,
  3386. })
  3387. .filter((n) => SEEDABLE.has(n.kind) && !isTestPath(n.filePath))
  3388. .filter((n) => {
  3389. const idx = n.name.toLowerCase().indexOf(lcToken);
  3390. if (idx < 0) return false;
  3391. if (idx === 0) return n.name.length > t.length; // prefix definer
  3392. return /[A-Z]/.test(n.name.charAt(idx)); // camel-hump boundary
  3393. })
  3394. .sort((a, b) => a.name.length - b.name.length)
  3395. .slice(0, 3);
  3396. }
  3397. // Bare lowercase words only seed defs their query-siblings corroborate
  3398. // (see the NL-stopword guard above). Filtering CANDS (not picks) applies
  3399. // the guard uniformly to both branches below, including the >3-def
  3400. // single-pick fallback — an uncorroborated bare `run` must not tier its
  3401. // most-substantive namesake any more than a 1-def `check` may.
  3402. if (!isPreciseToken(t)) {
  3403. cands = cands.filter((n) => coNamedInFile(t, n.filePath));
  3404. }
  3405. // A specific name (<=3 defs) injects all its defs. An overloaded name
  3406. // (`validate` = 10, `request` = 44) would flood the subgraph, so inject
  3407. // only: the overloads whose file/class the query ALSO names (the agent
  3408. // told us which one it wants — DataRequest's, not Validation.swift's),
  3409. // capped; else fall back to the single most-substantive def. This is the
  3410. // explore-side mirror of codegraph_node's overload disambiguation.
  3411. let picks: Node[];
  3412. let tierPicks: Node[]; // subset that earns the named-first tier (#1064)
  3413. if (cands.length <= 3) {
  3414. picks = cands;
  3415. // Centrality de-noise: tier the most-substantive def PLUS any co-named
  3416. // def of comparable centrality (a real overload/wrapper — excalidraw's
  3417. // `mutateElement` lives in mutateElement.ts, App.tsx AND Scene.ts, all
  3418. // within ~2x callers). EXCLUDE a vastly-less-central namesake (Go's
  3419. // `NewClient`: real client 492 callers vs xds-pool 11, test-fake 3 →
  3420. // ratio <0.025) so it doesn't fill the tier and crowd out the answer.
  3421. const counts = new Map(cands.map((c) => [c.id, callerCount(c)]));
  3422. const maxCallers = Math.max(1, ...counts.values());
  3423. tierPicks = cands.filter((c, i) => i === 0 || (counts.get(c.id) ?? 0) >= maxCallers * 0.25);
  3424. } else {
  3425. const ctx = cands.filter(inNamedContext);
  3426. picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
  3427. tierPicks = picks; // corroborated overloads (or the single fallback) all earn it
  3428. }
  3429. for (const n of picks) {
  3430. if (!subgraph.nodes.has(n.id)) subgraph.nodes.set(n.id, n);
  3431. // Mark as a named seed EVEN IF the FTS gather already had it — being
  3432. // "named by the agent" is independent of whether search happened to
  3433. // surface it, and it drives the +50 score, the gate, and the
  3434. // named-file sort below. (Previously only NEW injections were marked,
  3435. // so a named symbol FTS already gathered never sorted to the top.)
  3436. namedSeedIds.add(n.id);
  3437. }
  3438. for (const n of tierPicks) tierSeedIds.add(n.id);
  3439. }
  3440. }
  3441. // Step 2: Group nodes by file, score by relevance
  3442. // `peripheral` accumulates separately so it can be capped — see
  3443. // PERIPHERAL_SCORE_CAP; it is folded into `score` once the loop is done.
  3444. const fileGroups = new Map<string, { nodes: Node[]; score: number; peripheral: number }>();
  3445. const entryNodeIds = new Set([...subgraph.roots, ...namedSeedIds]);
  3446. // Build a set of nodes directly connected to entry points (depth 1)
  3447. const connectedToEntry = new Set<string>();
  3448. for (const edge of subgraph.edges) {
  3449. if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
  3450. if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
  3451. }
  3452. // Usage degree within the subgraph, for the weak-kind isolation test below.
  3453. // Free (the edges are already in hand) and it answers most cases; only a
  3454. // weak-kind node that looks isolated HERE pays for a DB probe.
  3455. const subgraphUsageDegree = new Map<string, number>();
  3456. for (const edge of subgraph.edges) {
  3457. if (!RELEVANCE_USAGE_EDGES.has(edge.kind) || edge.source === edge.target) continue;
  3458. subgraphUsageDegree.set(edge.source, (subgraphUsageDegree.get(edge.source) ?? 0) + 1);
  3459. subgraphUsageDegree.set(edge.target, (subgraphUsageDegree.get(edge.target) ?? 0) + 1);
  3460. }
  3461. /**
  3462. * Relevance weight for one matched symbol: its NodeKind, further discounted
  3463. * when it is a weak kind that NOTHING uses. The DB probe (full-graph, since
  3464. * a usage can sit outside the traversal) is paid for only by weak-kind
  3465. * symbols in the top two tiers — the ones whose weight can carry a whole
  3466. * file. A `connectedToEntry` or peripheral node is worth <= 3 either way, so
  3467. * probing it would buy nothing.
  3468. */
  3469. const isolationCache = new Map<string, boolean>();
  3470. const isUsageIsolated = (node: Node): boolean => {
  3471. if ((subgraphUsageDegree.get(node.id) ?? 0) > 0) return false;
  3472. const cached = isolationCache.get(node.id);
  3473. if (cached !== undefined) return cached;
  3474. let isolated = true;
  3475. try {
  3476. const used = (e: Edge) => RELEVANCE_USAGE_EDGES.has(e.kind);
  3477. isolated = !cg.getIncomingEdges(node.id).some(used)
  3478. && !cg.getOutgoingEdges(node.id).some(used);
  3479. } catch {
  3480. isolated = false; // a probe failure must not manufacture a penalty
  3481. }
  3482. isolationCache.set(node.id, isolated);
  3483. return isolated;
  3484. };
  3485. const relevanceWeight = (node: Node, probeIsolation: boolean): number => {
  3486. const weight = RELEVANCE_KIND_WEIGHT[node.kind] ?? DEFAULT_RELEVANCE_KIND_WEIGHT;
  3487. if (!probeIsolation || !WEAK_RELEVANCE_KINDS.has(node.kind)) return weight;
  3488. return isUsageIsolated(node) ? ISOLATED_WEAK_KIND_WEIGHT : weight;
  3489. };
  3490. // CHANGE SURFACE (#1064): a named method's signature types — its parameter
  3491. // and return types — are part of what you'd edit to "add a parameter to X",
  3492. // yet they can be lexically dissimilar to the query ("add a parameter to
  3493. // NewClient" shares no words with `dialoptions.go`, which defines NewClient's
  3494. // `DialOption`) and sit a hop away. COLLECT them here from each named-seed
  3495. // callable's outgoing signature edges (full graph — the type is often not in
  3496. // the subgraph); the decision to surface one is DEFERRED to the buried-rescue
  3497. // pass below, which fires only when the type's file would otherwise be
  3498. // dropped — so a well-connected type (excalidraw's element types, Alamofire's
  3499. // `DataRequest` on a flow query) is left to rank on its own and never
  3500. // displaces a flow-central file. Bounded: only the few named seeds, only the
  3501. // types in their signatures.
  3502. const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor']);
  3503. const TYPE_KINDS = new Set(['class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'type_alias']);
  3504. const SIG_EDGE = new Set(['references', 'type_of', 'returns']);
  3505. const changeSurfaceCandidates: Node[] = [];
  3506. const seenChangeSurface = new Set<string>();
  3507. for (const seedId of tierSeedIds) {
  3508. const seedNode = subgraph.nodes.get(seedId);
  3509. if (!seedNode || !CALLABLE_KINDS.has(seedNode.kind)) continue;
  3510. let outs: Edge[] = [];
  3511. try { outs = cg.getOutgoingEdges(seedId); } catch { continue; }
  3512. for (const e of outs) {
  3513. if (!SIG_EDGE.has(e.kind)) continue;
  3514. const tgt = cg.getNode(e.target);
  3515. if (!tgt || !TYPE_KINDS.has(tgt.kind) || namedSeedIds.has(tgt.id)) continue;
  3516. if (seenChangeSurface.has(tgt.id)) continue;
  3517. seenChangeSurface.add(tgt.id);
  3518. changeSurfaceCandidates.push(tgt);
  3519. }
  3520. }
  3521. for (const node of subgraph.nodes.values()) {
  3522. // Skip import/export nodes — they add noise without information
  3523. if (node.kind === 'import' || node.kind === 'export') continue;
  3524. // SECURITY (#383): never render the on-disk source of a config-leaf
  3525. // (Spring application.{yml,properties} key) — its line is `key = <secret>`,
  3526. // so whole-file/cluster rendering here would push secrets into context
  3527. // unbidden. The key still appears in the flow/symbol listing above.
  3528. if (isConfigLeafNode(node)) continue;
  3529. const group = fileGroups.get(node.filePath) || { nodes: [], score: 0, peripheral: 0 };
  3530. group.nodes.push(node);
  3531. // Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
  3532. // injected) is worth far more than a mere reference — its file is where the
  3533. // answer lives. Without this, an incidental file that name-drops the flow
  3534. // (Combine.swift references request/task → score 23 from connected nodes)
  3535. // outranks the file that DEFINES a named symbol (Validation.swift's
  3536. // `validate` → 10) and steals its render slot. Definition ≫ reference.
  3537. //
  3538. // Each tier is then scaled by WHAT was matched (RELEVANCE_KIND_WEIGHT): the
  3539. // tier says how the symbol reached us, the kind weight says whether the
  3540. // match is evidence. A file whose only claim is an unused local `explore`
  3541. // constant is a name collision, not an answer (#1500).
  3542. if (namedSeedIds.has(node.id)) {
  3543. group.score += 50 * relevanceWeight(node, true);
  3544. } else if (entryNodeIds.has(node.id)) {
  3545. group.score += 10 * relevanceWeight(node, true);
  3546. } else if (connectedToEntry.has(node.id)) {
  3547. group.score += 3 * relevanceWeight(node, false);
  3548. } else {
  3549. // Peripheral: in the subgraph but ≥2 hops from anything the query
  3550. // matched. Accumulated separately and capped below, so a file cannot
  3551. // buy relevance with size alone.
  3552. group.peripheral += relevanceWeight(node, false);
  3553. }
  3554. fileGroups.set(node.filePath, group);
  3555. }
  3556. // Extract query terms for relevance checking (path-stripped: a pinned
  3557. // file's own path fragments must not count as "term hits" everywhere)
  3558. const queryTerms = matchQuery.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  3559. // Test/spec/icon/i18n file detector — used by the pre-floor hard filter, the
  3560. // rank penalty, and the comparator deprioritization.
  3561. //
  3562. // The directory pattern is anchored at `^` as well as `/`: a repo-ROOT
  3563. // `test/` or `spec/` directory (express, cobra, and most of npm/Go) produced
  3564. // paths like `test/express.raw.js`, which the old leading-`/` form could
  3565. // never match — so express's routing question spent 59% of its envelope on
  3566. // three test files while `lib/router/index.js` never rendered.
  3567. const isLowValue = (p: string) => {
  3568. const lp = p.toLowerCase();
  3569. return (
  3570. /(?:^|\/)(tests?|__tests?__|specs?)\//.test(lp) ||
  3571. /_test\.go$/.test(lp) ||
  3572. /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
  3573. /_test\.py$/.test(lp) ||
  3574. /_spec\.rb$/.test(lp) ||
  3575. /_test\.rb$/.test(lp) ||
  3576. /\.(test|spec)\.[jt]sx?$/.test(lp) ||
  3577. /(test|spec|tests)\.(java|kt|scala)$/.test(lp) ||
  3578. /(tests?|spec)\.cs$/.test(lp) ||
  3579. /tests?\.swift$/.test(lp) ||
  3580. /_test\.dart$/.test(lp) ||
  3581. /\bicons?\b/.test(lp) ||
  3582. /\bi18n\b/.test(lp)
  3583. );
  3584. };
  3585. // One DB probe over every file the query touched, then O(1) per lookup.
  3586. // Unions the index-time content-banner flag (CG-5) with the filename
  3587. // convention, so a Go monorepo's generated CRUD (`payroll.go` carrying a
  3588. // DO-NOT-EDIT banner and nothing in its name) down-ranks the same way
  3589. // `.pb.go` always has (#1500). Covers the whole subgraph, not just the
  3590. // grouped files, because the graph-mass penalty below is keyed on it too.
  3591. const penaltyCandidates = new Set([
  3592. ...fileGroups.keys(),
  3593. ...[...subgraph.nodes.values()].map((n) => n.filePath),
  3594. ]);
  3595. const isGeneratedCandidate = cg.generatedFilePredicate(penaltyCandidates);
  3596. // Second bounded probe over the same set: files declaring nothing but types
  3597. // that nothing in the index depends on (CG-28). A query that NAMED one of
  3598. // those types is asking about the declaration, so its file is exempt and
  3599. // ranks at full weight.
  3600. const isAmbientDeclaration = cg.ambientDeclarationFilePredicate(penaltyCandidates);
  3601. const isDampedDeclaration = (filePath: string): boolean =>
  3602. isAmbientDeclaration(filePath) && !namedTypeFiles.has(filePath);
  3603. /**
  3604. * Rank penalty for a file, applied to its relevance score AND (below) to its
  3605. * graph mass — the two signals the sort actually keys on. Applying it to the
  3606. * score alone would leave the #1500 case unfixed: the generated CRUD carries
  3607. * MORE graph mass than the hand-written use-case, and graph mass outranks
  3608. * score in the comparator.
  3609. *
  3610. * Generated and ambient-declaration are taken as the STRONGER of the two,
  3611. * never multiplied: a generated `.d.ts` has one property — "not the
  3612. * implementation" — that both signals happen to see, and charging it twice is
  3613. * how a file gets cliffed out of answers where it is genuinely relevant
  3614. * (CG-28). The low-value multiplier is orthogonal (a test file that is also
  3615. * generated is two independent reasons) and still compounds.
  3616. */
  3617. const rankPenalty = (filePath: string): number =>
  3618. Math.min(
  3619. isGeneratedCandidate(filePath) ? GENERATED_RANK_PENALTY : 1,
  3620. isDampedDeclaration(filePath) ? AMBIENT_DECLARATION_RANK_PENALTY : 1,
  3621. )
  3622. * (isLowValue(filePath) ? LOW_VALUE_RANK_PENALTY : 1);
  3623. for (const [filePath, group] of fileGroups) {
  3624. group.score = (group.score + Math.min(PERIPHERAL_SCORE_CAP, group.peripheral))
  3625. * rankPenalty(filePath);
  3626. }
  3627. // Hard-exclude test/spec files (ALL tiers — the per-tier `excludeLowValueFiles`
  3628. // flag this used to be gated on was dead config and is gone). One slipped test
  3629. // file dominates the per-file budget on small repos (cobra's `command_test.go`
  3630. // displaced `args.go`) AND wastes budget on large ones (Django's
  3631. // `custom_lookups/tests.py` ate ~2.3 KB of the 28 KB cap, crowding out the
  3632. // SQLCompiler mechanism the agent then Read). A test file almost never answers
  3633. // an architecture question. Skip when the query itself is about tests — the
  3634. // legitimate "explore the tests" case — and only cut if ≥2 non-test candidates
  3635. // remain (else tests are the only signal for this area).
  3636. //
  3637. // Runs BEFORE the score floor, on the whole gather. Judging "are there other
  3638. // candidates?" on the post-floor set was too late: express's routing question
  3639. // left one non-test file past the floor, the guard stood down, and the floor's
  3640. // keep-minimum then pulled two test files back in as the "spread".
  3641. let candidateFiles = [...fileGroups.entries()];
  3642. {
  3643. const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(matchQuery);
  3644. if (!queryMentionsTests) {
  3645. // A pinned file is exempt: naming a test file by path IS asking for it.
  3646. const nonLow = candidateFiles.filter(([p]) => !isLowValue(p) || pinnedSet.has(p));
  3647. if (nonLow.length >= 2) {
  3648. candidateFiles = nonLow;
  3649. }
  3650. }
  3651. diag?.setLowValueFiltered(fileGroups.size, candidateFiles.length);
  3652. }
  3653. // Relative score floor — see SCORE_FLOOR_* for why it is a fraction of the
  3654. // best file's score and why that fraction is clamped at both ends.
  3655. const topScore = Math.max(0, ...candidateFiles.map(([, g]) => g.score));
  3656. const scoreFloor = Math.max(
  3657. SCORE_FLOOR_ABSOLUTE,
  3658. Math.min(SCORE_FLOOR_MAX, topScore * SCORE_FLOOR_FRACTION_OF_TOP),
  3659. );
  3660. let relevantFiles = candidateFiles.filter(
  3661. ([fp, group]) => group.score >= scoreFloor || pinnedSet.has(fp),
  3662. );
  3663. if (relevantFiles.length < SCORE_FLOOR_KEEP_MIN) {
  3664. // Backfill from what the RELATIVE floor cut, best first, at two strengths:
  3665. //
  3666. // - THIN (1-2 files survived): only files with real evidence. A file whose
  3667. // entire claim is one isolated variable scores 0.8 and stays out —
  3668. // express's `examples/route-middleware` matched nothing but a local
  3669. // `app` and would otherwise have taken 48% of that envelope. Padding a
  3670. // precise answer with a wrong file doesn't save the agent the follow-up
  3671. // call it would pad against.
  3672. // - EMPTY (nothing survived): take the best of whatever matched. Returning
  3673. // "no relevant code found" when the gather DID find candidates is the
  3674. // worst outcome on the board — the agent falls straight back to grep.
  3675. const minEvidence = relevantFiles.length === 0 ? Number.EPSILON : SCORE_FLOOR_ABSOLUTE;
  3676. relevantFiles = candidateFiles
  3677. .filter(([fp, group]) => group.score >= minEvidence || pinnedSet.has(fp))
  3678. .sort((a, b) =>
  3679. (pinnedSet.has(b[0]) ? 1 : 0) - (pinnedSet.has(a[0]) ? 1 : 0)
  3680. || b[1].score - a[1].score
  3681. || b[1].nodes.length - a[1].nodes.length)
  3682. .slice(0, Math.max(SCORE_FLOOR_KEEP_MIN, relevantFiles.length));
  3683. }
  3684. diag?.setScoreFloor(scoreFloor, relevantFiles.length);
  3685. // Secondary signal: how many DISTINCT query terms each file matches (path +
  3686. // symbol names). Kept only as a tiebreak — the PRIMARY relevance is graph
  3687. // connectivity below. (Term counting alone tied the real central file with
  3688. // incidental same-word matches; it's a weak text signal, not the ranker.)
  3689. const uniqueQueryTerms = [...new Set(queryTerms)].filter(t => t.length >= 3);
  3690. const fileTermHits = new Map<string, number>();
  3691. for (const [fp, group] of relevantFiles) {
  3692. const hay = fp.toLowerCase() + ' ' + group.nodes.map(n => n.name.toLowerCase()).join(' ');
  3693. let hits = 0;
  3694. for (const t of uniqueQueryTerms) if (hay.includes(t)) hits++;
  3695. fileTermHits.set(fp, hits);
  3696. }
  3697. // PRIMARY relevance: graph connectivity (Random-Walk-with-Restart from the
  3698. // matched seeds — see computeGraphRelevance). Aggregate each file's nodes'
  3699. // walk mass. This is the signal text search lacks: the real cluster
  3700. // (org-user.storage.ts, call-connected to the matches) accrues mass; a lone
  3701. // text match (LensSwitcher.swift, matched "switch" but calls nothing in the
  3702. // flow) gets only its restart probability → ~0, and is dropped by the gate.
  3703. const nodeRwr = this.computeGraphRelevance(
  3704. [...subgraph.nodes.keys()], subgraph.edges, entryNodeIds,
  3705. );
  3706. //
  3707. // Carries `rankPenalty` too, so generated/low-value files are demoted on the
  3708. // sort's PRIMARY key rather than only at the tiebreak. Everything downstream
  3709. // (centrality, the relevance gate, the buried-rescue test, the comparator)
  3710. // reads this map, so the penalty applies once and applies everywhere.
  3711. const fileGraphScore = new Map<string, number>();
  3712. for (const node of subgraph.nodes.values()) {
  3713. fileGraphScore.set(
  3714. node.filePath,
  3715. (fileGraphScore.get(node.filePath) ?? 0) + (nodeRwr.get(node.id) ?? 0),
  3716. );
  3717. }
  3718. for (const [fp, mass] of fileGraphScore) fileGraphScore.set(fp, mass * rankPenalty(fp));
  3719. const maxGraph = Math.max(0, ...fileGraphScore.values());
  3720. // Central file(s): the 1-2 most graph-central files that also match the
  3721. // query textually (so a connected hub-utility with no term match isn't
  3722. // mistaken for the subject). The heart of the answer — they earn the larger
  3723. // WHOLE-FILE ceiling below (a god-file central file still exceeds it and
  3724. // falls to generous full-method sectioning — never a whole dump).
  3725. const centralFiles = new Set(
  3726. [...fileGraphScore.entries()]
  3727. .filter(([fp, g]) => g > 0 && (fileTermHits.get(fp) ?? 0) >= 1)
  3728. .sort((a, b) => b[1] - a[1] || (fileTermHits.get(b[0]) ?? 0) - (fileTermHits.get(a[0]) ?? 0))
  3729. .slice(0, 2)
  3730. .map(([f]) => f),
  3731. );
  3732. // Files that DEFINE a symbol the agent named (or a subgraph root). These are
  3733. // the highest-relevance files there are — the agent asked for them by name —
  3734. // so the connectivity gate below must never drop them, even when their RWR
  3735. // mass is low (a leaf family file like codec.ts is call-connected to little
  3736. // but is exactly what the agent queried). Without this protection the gate
  3737. // prunes a named file and the agent Reads it back.
  3738. const entryFiles = new Set<string>();
  3739. for (const id of entryNodeIds) {
  3740. const n = subgraph.nodes.get(id);
  3741. if (n) entryFiles.add(n.filePath);
  3742. }
  3743. // Buried-rescue pass (#1064): surface a named method's signature type ONLY
  3744. // when its file is genuinely buried — near-zero graph mass AND not lexically
  3745. // matched. That is the invisible case (grpc's `DialOption` → `dialoptions.go`,
  3746. // g≈0, 0 term hits): reachable but ranked nowhere, so the agent greps. A
  3747. // well-connected type file (excalidraw element types, Alamofire `DataRequest`)
  3748. // is NOT buried and is left alone — rescuing it would displace a flow-central
  3749. // file (App.tsx, Validation.swift). Buried is judged on the PRE-rescue graph,
  3750. // so injecting the type below can't make it look connected. A rescued file is
  3751. // injected (so it renders), force-kept (gate + relevantFiles), and tiered.
  3752. const changeSurfaceFiles = new Set<string>();
  3753. for (const t of changeSurfaceCandidates) {
  3754. const fp = t.filePath;
  3755. const buried = (fileGraphScore.get(fp) ?? 0) < maxGraph * 0.06
  3756. && (fileTermHits.get(fp) ?? 0) < 2;
  3757. if (!buried) continue;
  3758. changeSurfaceFiles.add(fp);
  3759. if (!subgraph.nodes.has(t.id)) subgraph.nodes.set(t.id, t);
  3760. let group = fileGroups.get(fp);
  3761. if (!group) { group = { nodes: [], score: 0, peripheral: 0 }; fileGroups.set(fp, group); }
  3762. if (!group.nodes.some((n) => n.id === t.id)) group.nodes.push(t);
  3763. group.score = Math.max(group.score, 45);
  3764. if (!relevantFiles.some(([f]) => f === fp)) relevantFiles.push([fp, group]);
  3765. }
  3766. // Relevance gate (so the generous budget is a CEILING, not a target): keep a
  3767. // file only if it is STRUCTURALLY relevant by ANY of:
  3768. // - graph score within a fraction of the top (it's on/near the flow), OR
  3769. // - central (a query entry-point lives here), OR
  3770. // - it DEFINES a symbol the agent named (entryFiles), OR
  3771. // - it matches >= 2 DISTINCT named query terms — a strong text signal that
  3772. // the agent is asking about this file even when nothing calls it (codec.ts:
  3773. // the agent named `encode`/`Codec`/`JsonCodec`, all leaf classes with zero
  3774. // RWR mass — graph alone wrongly drops it).
  3775. // A lone text match on one shared word (LensSwitcher: term=1, g~0) is still
  3776. // dropped, so the budget never fills with incidental files. Guarded so it
  3777. // never prunes below 2.
  3778. if (maxGraph > 0) {
  3779. const gated = relevantFiles.filter(([fp]) =>
  3780. pinnedSet.has(fp)
  3781. || (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
  3782. || centralFiles.has(fp)
  3783. || entryFiles.has(fp)
  3784. || changeSurfaceFiles.has(fp)
  3785. || (fileTermHits.get(fp) ?? 0) >= 2,
  3786. );
  3787. if (gated.length >= 2) relevantFiles = gated;
  3788. diag?.setRelevanceGate(maxGraph, maxGraph * 0.06, gated.length >= 2, relevantFiles.length);
  3789. } else {
  3790. diag?.setRelevanceGate(maxGraph, 0, false, relevantFiles.length);
  3791. }
  3792. // Sort files: graph-central first, then distinct-term match, then the
  3793. // existing low-value/generated/score tiebreaks.
  3794. // Files that DEFINE a symbol the agent NAMED. These sort first — ahead of
  3795. // graph connectivity — because the agent asked for them by name. Without
  3796. // this, a named leaf override reached only by dynamic dispatch (Alamofire's
  3797. // `DataRequest.task`/`validate`, low RWR mass) sorts below the high-
  3798. // connectivity abstract base (`Request.swift`) and the same-named overloads
  3799. // in other files (`Validation.swift`), falls outside the budget, and the
  3800. // agent Reads it. The named file is the answer — rank it at the top.
  3801. const namedSeedFiles = new Set<string>();
  3802. for (const id of tierSeedIds) {
  3803. const n = subgraph.nodes.get(id);
  3804. if (n) namedSeedFiles.add(n.filePath);
  3805. }
  3806. // A rescued change-surface file (only the genuinely-buried ones — see the
  3807. // buried-rescue pass) is the lexically-dissimilar answer; give it the named
  3808. // tier so it isn't buried under files that merely share surface words (#1064).
  3809. for (const fp of changeSurfaceFiles) namedSeedFiles.add(fp);
  3810. // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
  3811. // (a search root, named seed, or graph-central hub — i.e. structurally part of
  3812. // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
  3813. // graph-centrality mass that accrued to a denser-but-off-topic cluster. In a
  3814. // cross-layer monorepo (an API server alongside a much larger, internally dense
  3815. // frontend that mirrors the same domain words) the Random-Walk-with-Restart mass
  3816. // — seeded from text matches that skew to the bigger layer — floats hits=0
  3817. // frontend files above the hits=2/3 backend service that IS the answer (its many
  3818. // callers don't help: it's call-isolated from the frontend seed cluster). The
  3819. // entry/central GUARD keeps this safe: an INCIDENTAL multi-term file that is
  3820. // neither entry nor central (a type/util file that matches "element"+x but isn't
  3821. // the flow) is NOT promoted, so it can't displace the graph-central answer file
  3822. // (hits=1) the way a blunt hits-only tier would. Single-layer repos with one
  3823. // cluster are unaffected (no competing mass). Set CODEGRAPH_RANK_NO_MULTITERM=1
  3824. // to disable.
  3825. const MULTITERM_OFF = process.env.CODEGRAPH_RANK_NO_MULTITERM === '1';
  3826. const isCorroborated = (fp: string) =>
  3827. !MULTITERM_OFF &&
  3828. (fileTermHits.get(fp) ?? 0) >= 2 &&
  3829. (entryFiles.has(fp) || centralFiles.has(fp));
  3830. const sortedFiles = relevantFiles.sort((a, b) => {
  3831. const aPath = a[0].toLowerCase();
  3832. const bPath = b[0].toLowerCase();
  3833. // Pinned files first of all — the agent named the FILE by path, which is
  3834. // even more explicit than naming a symbol in it. Among pins, keep the
  3835. // order they appeared in the query.
  3836. const aPin = pinnedSet.has(a[0]) ? 1 : 0;
  3837. const bPin = pinnedSet.has(b[0]) ? 1 : 0;
  3838. if (aPin !== bPin) return bPin - aPin;
  3839. if (aPin && bPin) return (pinnedOrder.get(a[0]) ?? 0) - (pinnedOrder.get(b[0]) ?? 0);
  3840. // Agent-named files next (it asked for a symbol defined here by name).
  3841. const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
  3842. const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
  3843. if (aNamed !== bNamed) return bNamed - aNamed;
  3844. // Corroborated (entry/central + ≥2 terms) tier, above the graph signal.
  3845. const aCorr = isCorroborated(a[0]) ? 1 : 0;
  3846. const bCorr = isCorroborated(b[0]) ? 1 : 0;
  3847. if (aCorr !== bCorr) return bCorr - aCorr;
  3848. // Graph connectivity is the next key (small epsilon so near-ties fall
  3849. // through to the text signal rather than coin-flipping on float noise).
  3850. const aG = fileGraphScore.get(a[0]) ?? 0;
  3851. const bG = fileGraphScore.get(b[0]) ?? 0;
  3852. if (Math.abs(aG - bG) > maxGraph * 0.01) return bG - aG;
  3853. const aHits = fileTermHits.get(a[0]) ?? 0;
  3854. const bHits = fileTermHits.get(b[0]) ?? 0;
  3855. if (aHits !== bHits) return bHits - aHits;
  3856. const aLow = isLowValue(aPath);
  3857. const bLow = isLowValue(bPath);
  3858. if (aLow !== bLow) return aLow ? 1 : -1;
  3859. // Deprioritize generated source (.pb.go / .pulsar.go / _mocks.go / …) —
  3860. // the agent rarely needs to see the protobuf scaffold or gomock output
  3861. // when asking about the actual flow, and dumping their bodies inflates
  3862. // the response (the cosmos Q3 explore otherwise leads with
  3863. // `expected_keepers_mocks.go`, displacing the real `tally.go` content
  3864. // and forcing the agent to Read tally.go anyway). Both this and the
  3865. // low-value key above are now BACKSTOPS: `rankPenalty` has already scaled
  3866. // the score and the graph mass these files reach this comparison with, so
  3867. // a generated file no longer outranks a hand-written one just by scoring
  3868. // higher (#1500). This still settles the exact ties the penalty leaves.
  3869. const aGen = isGeneratedCandidate(a[0]);
  3870. const bGen = isGeneratedCandidate(b[0]);
  3871. if (aGen !== bGen) return aGen ? 1 : -1;
  3872. if (a[1].score !== b[1].score) return b[1].score - a[1].score;
  3873. return b[1].nodes.length - a[1].nodes.length;
  3874. });
  3875. // Step 3: Build relationship map
  3876. const lines: string[] = [
  3877. `**Exploration: ${query}**`,
  3878. '',
  3879. // Curated summary — filled in after the source loop (see below). We do NOT
  3880. // report `subgraph.nodes.size` / `fileGroups.size` here: that's the raw
  3881. // candidate gather, which a broad natural-language query inflates wildly
  3882. // (260 symbols / 124 files on a 636-file repo) even though only a handful
  3883. // render. Reporting the pool read as "260 results to wade through" when the
  3884. // real, correctly-ranked answer is the few files below (#1046).
  3885. '',
  3886. '',
  3887. ];
  3888. const summaryLineIdx = 2;
  3889. // Blast radius (always-on, compact): for the entry symbols, who depends on
  3890. // them + which tests cover them — locations only, no source — so the agent
  3891. // knows what to update/verify before editing without a separate call.
  3892. const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
  3893. if (blastRadius) lines.push(blastRadius);
  3894. // Relationship map — show how symbols connect
  3895. const significantEdges = subgraph.edges.filter(e =>
  3896. e.kind !== 'contains' // skip contains — it's implied by file grouping
  3897. );
  3898. if (budget.includeRelationships && significantEdges.length > 0) {
  3899. lines.push('**Relationships**');
  3900. lines.push('');
  3901. // Group edges by kind for readability
  3902. const byKind = new Map<string, Array<{ source: string; target: string }>>();
  3903. for (const edge of significantEdges) {
  3904. const sourceNode = subgraph.nodes.get(edge.source);
  3905. const targetNode = subgraph.nodes.get(edge.target);
  3906. if (!sourceNode || !targetNode) continue;
  3907. const group = byKind.get(edge.kind) || [];
  3908. group.push({ source: sourceNode.name, target: targetNode.name });
  3909. byKind.set(edge.kind, group);
  3910. }
  3911. for (const [kind, edges] of byKind) {
  3912. const cap = budget.maxEdgesPerRelationshipKind;
  3913. const shown = edges.slice(0, cap);
  3914. lines.push(`**${kind}:**`);
  3915. for (const e of shown) {
  3916. lines.push(`- ${e.source} → ${e.target}`);
  3917. }
  3918. if (edges.length > cap) {
  3919. lines.push(`- ... and ${edges.length - cap} more`);
  3920. }
  3921. lines.push('');
  3922. }
  3923. }
  3924. // Step 4: Read contiguous file sections
  3925. // Compute the flow spine once — used both to prepend the Flow section (below)
  3926. // and to gate adaptive source sizing: files on the spine get full source,
  3927. // off-spine peers skeletonize.
  3928. const flow = this.buildFlowFromNamedSymbols(cg, matchQuery);
  3929. // Snapshot every ranked candidate's scoring inputs, in final sort order, so
  3930. // the diagnostic can show what each file's share of the envelope was BOUGHT
  3931. // with (score, graph mass, term hits, flags) — not just what it cost.
  3932. if (diag) {
  3933. const kindMix = (nodes: Node[]): string => {
  3934. const counts = new Map<string, number>();
  3935. for (const n of nodes) counts.set(n.kind, (counts.get(n.kind) ?? 0) + 1);
  3936. return [...counts.entries()]
  3937. .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
  3938. .map(([k, c]) => `${k}:${c}`)
  3939. .join(' ');
  3940. };
  3941. sortedFiles.forEach(([fp, group], i) => {
  3942. diag.noteCandidate(fp, {
  3943. rank: i + 1,
  3944. score: group.score,
  3945. graphScore: fileGraphScore.get(fp) ?? 0,
  3946. termHits: fileTermHits.get(fp) ?? 0,
  3947. nodes: group.nodes.length,
  3948. pinned: pinnedSet.has(fp),
  3949. named: namedSeedFiles.has(fp),
  3950. central: centralFiles.has(fp),
  3951. entry: entryFiles.has(fp),
  3952. spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
  3953. lowValue: isLowValue(fp),
  3954. generated: isGeneratedCandidate(fp),
  3955. ambientDeclaration: isAmbientDeclaration(fp),
  3956. penalty: rankPenalty(fp),
  3957. kinds: kindMix(group.nodes),
  3958. });
  3959. });
  3960. }
  3961. // Score-proportional byte allocation (CG-12). Every file's share of the
  3962. // envelope is reserved HERE, before a single byte renders, so the render loop
  3963. // spends a reservation instead of racing for whatever the files above it left.
  3964. const allocation = allocateExploreBudget(
  3965. sortedFiles.map(([fp, group]) => ({
  3966. path: fp,
  3967. score: group.score,
  3968. // A pinned file's bytes are worth full price by definition — the agent
  3969. // asked for the file itself, generated/test or not.
  3970. worth: pinnedSet.has(fp) ? 1 : rankPenalty(fp),
  3971. spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
  3972. pinned: pinnedSet.has(fp),
  3973. })),
  3974. budget,
  3975. maxFiles,
  3976. );
  3977. diag?.setAllocation(allocation.allowances, allocation.cliffed, allocation.cliffAt, allocation.pool);
  3978. // Cliffed files ship as pointers — path, symbols, line numbers — so the agent
  3979. // can name one in a follow-up explore. Rendered below with the other
  3980. // not-shown files, and force-enabled even on tiers that suppress that list:
  3981. // a file we deliberately withheld source for must still be nameable.
  3982. const cliffedFiles = new Set(allocation.cliffed);
  3983. // Polymorphic-sibling detector for adaptive sizing. A class that implements/
  3984. // extends a supertype shared by >= MIN_SIBLINGS classes is one of many
  3985. // INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
  3986. // showing one + the rest as signatures is enough), as opposed to a DISTINCT
  3987. // pipeline step (Excalidraw's `renderStaticScene`, which shares no supertype and
  3988. // must stay full or the agent loses real content). Only off-spine sibling files
  3989. // skeletonize; distinct steps and on-spine files keep full source. Cache
  3990. // supertype→(has ≥N implementers) so this stays a handful of edge queries.
  3991. const MIN_SIBLINGS = 3;
  3992. const siblingSuper = new Map<string, boolean>();
  3993. const isPolymorphicSibling = (nodes: Node[]): boolean => {
  3994. for (const n of nodes) {
  3995. for (const e of cg.getOutgoingEdges(n.id)) {
  3996. if (e.kind !== 'implements' && e.kind !== 'extends') continue;
  3997. let many = siblingSuper.get(e.target);
  3998. if (many === undefined) {
  3999. many = cg.getIncomingEdges(e.target)
  4000. .filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
  4001. siblingSuper.set(e.target, many);
  4002. }
  4003. if (many) return true;
  4004. }
  4005. }
  4006. return false;
  4007. };
  4008. // A file that DEFINES a polymorphic supertype (a class/interface with ≥
  4009. // MIN_SIBLINGS implementers) AND co-locates its subclasses is a redundant
  4010. // "family" file — Django's compiler.py holds `SQLCompiler` + its 4 subclasses
  4011. // (SQLInsert/Update/Delete/AggregateCompiler) in 2,266 lines. Such files are
  4012. // huge and read-anyway, so they should STILL skeletonize even when the agent
  4013. // named a method in them: a full one eats ~6.5K of the explore budget (Django
  4014. // is pinned at the 28K cap, truncating), starving the sibling files the agent
  4015. // then Reads. This flag OVERRIDES the named-callable spare below — it does NOT
  4016. // by itself spare a file. (OkHttp's RealCall implements the `Lockable` mixin
  4017. // but defines no ≥3-impl supertype, so the named spare keeps it full.)
  4018. const superMany = new Map<string, boolean>();
  4019. const definesPolymorphicSupertype = (nodes: Node[]): boolean => {
  4020. for (const n of nodes) {
  4021. if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct' && n.kind !== 'union'
  4022. && n.kind !== 'trait' && n.kind !== 'protocol' && n.kind !== 'type_alias') continue;
  4023. let many = superMany.get(n.id);
  4024. if (many === undefined) {
  4025. many = cg.getIncomingEdges(n.id)
  4026. .filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
  4027. superMany.set(n.id, many);
  4028. }
  4029. if (many) return true;
  4030. }
  4031. return false;
  4032. };
  4033. lines.push('**Source Code**');
  4034. lines.push('');
  4035. // Recorded so the drift pass below (#1474) can append a per-file exception
  4036. // to this guarantee after the render loop knows which files drifted.
  4037. const verbatimHeaderIdx = lines.length;
  4038. 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.');
  4039. lines.push('');
  4040. // The response's absolute cap. It MUST stay under the host's inline
  4041. // tool-result limit (~25K chars): above it the result is externalized to a
  4042. // file the agent Reads back (a 35K vscode explore did exactly this in the
  4043. // n=4 A/B).
  4044. const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
  4045. // What the epilogue is OWED — the part of it the loop must not spend (CG-26).
  4046. // Not a flat margin: the old 600 was neither the epilogue's size (1,064 on
  4047. // gin, 2,231 on excalidraw) nor a bound on it, so the loop budgeted for a
  4048. // thing that did not exist and the response then discarded the whole
  4049. // epilogue to fit. The floor is what the epilogue owes the AGENT rather
  4050. // than what it costs us:
  4051. // - the one-line note that says an uncovered area exists (always), and
  4052. // - a pointer for every file whose source was deliberately WITHHELD.
  4053. // A cliffed file's bytes were traded away on the promise that the agent
  4054. // can still name it in a follow-up call (CG-12); if the ceiling then
  4055. // eats that name the trade was a silent drop.
  4056. // Everything above the floor — the rest of the pointer list, the reminders
  4057. // — is elastic and fitted to the room that is actually left, at the end of
  4058. // this method. Sized from the REAL strings, never tuned: a constant swept
  4059. // against the suite is what CG-30's record warns about.
  4060. const cliffPointerFloor = [...cliffedFiles]
  4061. .slice(0, POINTER_MAX_FILES)
  4062. .reduce((n, fp) => {
  4063. const g = fileGroups.get(fp);
  4064. return g ? n + pointerLineFor(fp, g.nodes).length + 1 : n;
  4065. }, cliffedFiles.size > 0 ? POINTER_HEADER.length + 2 : 0);
  4066. const epilogueFloor = EPILOGUE_LOST_NOTE.length + 2 + cliffPointerFloor;
  4067. // Absolute stop for the render loop. Reservations already fit the envelope, so
  4068. // this only catches their bounded overshoot (the whole-file grace, an oversize
  4069. // first cluster) — and catches it HERE, where a file can be skipped cleanly and
  4070. // a later one still render, instead of at the final truncation, which lops off
  4071. // whichever section happened to land last.
  4072. const renderCeiling = hardCeiling - epilogueFloor;
  4073. // `flow.text` is PART of the response — it is prepended to `lines` to make
  4074. // the final output — so the render loop has to spend against it, and it
  4075. // never did. Counting it is what makes `renderCeiling` the ceiling it
  4076. // claims to be: without it the loop believed it had room for a trailing
  4077. // section the final truncation then threw away whole, and (CG-31) the
  4078. // displacement guard dutifully held bytes back to pay for that section —
  4079. // taking them off a file the agent DOES receive and handing them to one it
  4080. // never sees.
  4081. let totalChars = flow.text.length + lines.join('\n').length;
  4082. let filesIncluded = 0;
  4083. // Paths we actually render source for below. Drives the curated header count
  4084. // (#1046) — it must reflect what we show, not the raw candidate gather.
  4085. const renderedFilePaths: string[] = [];
  4086. let anyFileTrimmed = false;
  4087. // Files that changed on disk after their last index sync (#1474). Their
  4088. // indexed line ranges are untrustworthy, so sliced renders (adaptive /
  4089. // skeleton / clusters) are OFF for them: a small drifted file still ships
  4090. // whole (current bytes, correct by construction → staleRendered), a big one
  4091. // is omitted with an explicit notice (→ staleOmitted) — honest absence
  4092. // instead of a different symbol's code under the requested name.
  4093. const staleRendered: string[] = [];
  4094. const staleOmitted: string[] = [];
  4095. // Anti-abandonment hold-back (CG-18). The first file dedup suppressed
  4096. // ENTIRELY, kept with its real section so it can be put back if the loop
  4097. // ends with no new source anywhere. A response made only of pointers is the
  4098. // shape that reads as "codegraph has nothing" — and one such response early
  4099. // in a session is enough to make an agent stop calling the tool at all — so
  4100. // the highest-ranked suppressed file is restored rather than risk it. It
  4101. // costs a re-serve of one file, on the one call shape where dedup would
  4102. // otherwise have saved everything: the safe direction.
  4103. type SuppressedFallback = {
  4104. filePath: string;
  4105. /** Index in `lines` where this file's pointer block starts. */
  4106. at: number;
  4107. /** How many `lines` entries the pointer block occupies. */
  4108. replacing: number;
  4109. /** The full, undeduped section to splice back in. */
  4110. section: string[];
  4111. sourceChars: number;
  4112. overhead: number;
  4113. ranges: ExploreLineRange[];
  4114. fingerprint: string;
  4115. };
  4116. let suppressedFallback: SuppressedFallback | null = null;
  4117. // Reservation carry-forward (CG-21). A reservation is a promise the render
  4118. // loop has to KEEP, not a cap it may quietly under-use: a file that cannot
  4119. // spend what it was given — thin matched-symbol set, unreadable, drifted off
  4120. // disk, skipped for the ceiling — must hand the difference DOWN the rank
  4121. // order, not drop it. Tracked as two running totals rather than a `spent`
  4122. // variable threaded through the dozen `continue`s below, so no exit path can
  4123. // forget to account: everything the loop has PROMISED so far, and everything
  4124. // it has actually EMITTED. Their gap is the slack the next file may add to
  4125. // its own reservation.
  4126. //
  4127. // Symmetric on the other side: a whole-file buy that overshoots makes
  4128. // `sourceSpent` outrun `reservedSoFar`, which suppresses slack until a later
  4129. // under-spend covers the debt. So the pool is conserved in both directions,
  4130. // and no file is ever cut BELOW the reservation it was promised.
  4131. let reservedSoFar = 0;
  4132. let sourceSpent = 0;
  4133. // Funding line for the whole-file BUY rule: the response's SOURCE may reach
  4134. // everything the allocator promised plus one bounded overshoot, and no more.
  4135. // Measured against the promise rather than `renderCeiling` on purpose — the
  4136. // ceiling sits 50% above the envelope and says nothing about who is owed
  4137. // what, so funding a buy from it just moves the shortfall to whichever file
  4138. // the loop reaches last. See WHOLE_FILE_BUY_OVERSHOOT_FRACTION.
  4139. const reservedTotal = [...allocation.allowances.values()].reduce((sum, n) => sum + n, 0);
  4140. const sourceCeiling = reservedTotal + Math.round(
  4141. budget.maxOutputChars * EXPLORE_ALLOCATION.WHOLE_FILE_BUY_OVERSHOOT_FRACTION,
  4142. );
  4143. /**
  4144. * What a file's section costs BESIDES its source, in render space: the
  4145. * header (path + up to `maxSymbolsInFileHeader` symbol names) plus the code
  4146. * fence and the blank lines around them (CG-26).
  4147. *
  4148. * `EXPLORE_ALLOCATION.FILE_OVERHEAD` is the ALLOCATOR's constant — the flat
  4149. * 200 it charges each admitted file when it splits the envelope — and using
  4150. * it here too was a category error worth ~250 chars per pending file: the
  4151. * render loop then held back a file's reservation but not the header that
  4152. * reservation has to arrive under, so the last admitted file was left just
  4153. * short of the room it needed and skipped whole. Estimated from the file's
  4154. * own candidate symbols, which is what the header is actually built from.
  4155. */
  4156. const overheadCache = new Map<string, number>();
  4157. const sectionOverhead = (filePath: string, nodes: readonly Node[]): number => {
  4158. const hit = overheadCache.get(filePath);
  4159. if (hit !== undefined) return hit;
  4160. const names = [...new Set(
  4161. nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export')
  4162. .map((n) => `${n.name}(${n.kind})`),
  4163. )].slice(0, budget.maxSymbolsInFileHeader);
  4164. // header + blank, then ```lang / body / ``` / blank around the source.
  4165. const cost = fileSectionHeader(filePath, names.join(', ')).length + 2
  4166. + (nodes[0]?.language?.length ?? 0) + 11;
  4167. overheadCache.set(filePath, cost);
  4168. return cost;
  4169. };
  4170. /**
  4171. * How much of what is still owed BELOW `fileIndex` the response can actually
  4172. * still PAY, in render-space chars (CG-31).
  4173. *
  4174. * Not the same as the sum of those reservations. The allocator splits the
  4175. * envelope; the render loop spends against a ceiling that also has to hold
  4176. * the response's own prose, so on a saturated response the promises are
  4177. * OVER-SUBSCRIBED and the tail is going to be dropped whatever happens
  4178. * above it. Bytes held back for a file that then gets dropped are bytes
  4179. * nobody ever receives — measured on django, holding the full owed sum cost
  4180. * the rank-#1 file 2,126 chars and handed them to a rank-#6 section the
  4181. * hard ceiling threw away. So walk the remaining files in RANK order and
  4182. * hold back only the prefix that fits `budgetLeft`; the first one that does
  4183. * not fit ends it, because everything after it is further out of reach.
  4184. *
  4185. * Conservative and self-correcting: it assumes each file below spends its
  4186. * whole reservation, and when they do not, the carry-forward hands the
  4187. * difference to whoever comes next anyway.
  4188. */
  4189. const owedPayableBelow = (fileIndex: number, budgetLeft: number): number => {
  4190. let held = 0;
  4191. for (let j = fileIndex + 1; j < sortedFiles.length; j++) {
  4192. const path = sortedFiles[j]![0];
  4193. const r = allocation.allowances.get(path);
  4194. if (r === undefined) continue;
  4195. const overhead = sectionOverhead(path, sortedFiles[j]![1].nodes);
  4196. const need = r + overhead;
  4197. if (held + need > budgetLeft) {
  4198. // PART of a reservation is still a delivered file (CG-26). Holding
  4199. // all-or-nothing zeroed the last admitted file whenever its full
  4200. // reservation no longer fit: on the precise-query fixture the rank-5
  4201. // file took 4,134 chars against a 2,948 reservation while rank 6 —
  4202. // admitted, reserved 2,539 — was left 4 chars and skipped. Hold the
  4203. // remainder instead, but only while it is still worth a section:
  4204. // under MIN_CHARS a slice cannot hold one complete method, and a
  4205. // fragment forces the Read this tool exists to prevent.
  4206. const partial = budgetLeft - held;
  4207. if (partial >= EXPLORE_ALLOCATION.MIN_CHARS + overhead) held += partial;
  4208. break;
  4209. }
  4210. held += need;
  4211. }
  4212. return held;
  4213. };
  4214. for (let fileIndex = 0; fileIndex < sortedFiles.length; fileIndex++) {
  4215. const [filePath, group] = sortedFiles[fileIndex]!;
  4216. if (filesIncluded >= maxFiles) {
  4217. if (diag) for (const [fp] of sortedFiles) diag.recordSkip(fp, 'max-files');
  4218. break;
  4219. }
  4220. // Below the relevance cliff: no source, no `maxFiles` slot. It is still
  4221. // named — with its matched symbols and their line numbers — in the
  4222. // not-shown list, so one follow-up explore fetches it in full.
  4223. if (cliffedFiles.has(filePath)) {
  4224. diag?.recordSkip(filePath, 'cliff');
  4225. continue;
  4226. }
  4227. // This file's reserved share of the envelope. Every render path below is
  4228. // bounded by it instead of by the flat per-file cap, which is what stops
  4229. // allocation from following file size: a small weakly-relevant file no
  4230. // longer ships whole while the strongly-relevant one is clipped.
  4231. const reserved = allocation.allowances.get(filePath);
  4232. if (reserved === undefined) {
  4233. diag?.recordSkip(filePath, 'max-files');
  4234. continue;
  4235. }
  4236. // What this file may actually spend: its own reservation PLUS whatever the
  4237. // files above it left on the table. Every render bound below reads this,
  4238. // never `reserved` — that is what makes the carry-forward reach the render
  4239. // paths instead of being bookkeeping. Slack flows to the next file in RANK
  4240. // order because that is the only file a single-pass loop can still pay;
  4241. // `MAX_SHARE` keeps that from turning a weak tail file into the response,
  4242. // so the allocator's share ceiling holds end-to-end and not just at
  4243. // reservation time.
  4244. const allowance = Math.min(
  4245. reserved + Math.max(0, reservedSoFar - sourceSpent),
  4246. Math.max(reserved, Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE)),
  4247. );
  4248. reservedSoFar += reserved;
  4249. diag?.recordSpendable(filePath, allowance);
  4250. // DISPLACEMENT GUARD, in render space (CG-31). `allowance` says what this
  4251. // file MAY spend; it does not say the bytes are still there to spend. The
  4252. // hard ceiling is shared with every file the loop has not reached yet, and
  4253. // their reservations are promises the allocator already made — so what is
  4254. // left before the ceiling is not all ours. Holding that back is the same
  4255. // inequality the whole-file BUY arm enforces with `owedBelow` (see below),
  4256. // moved into the units the cluster path actually spends in: source PLUS
  4257. // the per-section overhead each pending file will charge.
  4258. //
  4259. // Held back only where it can be PAID — see `owedPayableBelow`. A promise
  4260. // the ceiling cannot reach is not a claim on this file's bytes; honouring
  4261. // it anyway just moves source from a file the agent gets to one it does
  4262. // not.
  4263. //
  4264. // Floored at this file's OWN reservation, never below: a kept promise is
  4265. // not a displacement, and cutting a file under what it earned is the
  4266. // failure this whole allocation layer exists to prevent.
  4267. //
  4268. // Slack still reaches the file: a file above that under-spends leaves
  4269. // `totalChars` lower, which raises `headroom` one-for-one, so the
  4270. // carry-forward the `allowance` line grants is exactly the carry-forward
  4271. // this bound funds.
  4272. const headroom = Math.max(0, renderCeiling - totalChars - sectionOverhead(filePath, group.nodes));
  4273. const fundedHeadroom = Math.max(
  4274. Math.min(reserved, headroom),
  4275. headroom - owedPayableBelow(fileIndex, Math.max(0, headroom - reserved)),
  4276. );
  4277. diag?.recordFunded(filePath, fundedHeadroom);
  4278. const absPath = validatePathWithinRoot(projectRoot, filePath);
  4279. if (!absPath || !existsSync(absPath)) {
  4280. diag?.recordSkip(filePath, 'unreadable');
  4281. continue;
  4282. }
  4283. let fileContent: string;
  4284. try {
  4285. fileContent = readFileSync(absPath, 'utf-8');
  4286. } catch {
  4287. diag?.recordSkip(filePath, 'unreadable');
  4288. continue;
  4289. }
  4290. const fileLines = fileContent.split('\n');
  4291. const lang = group.nodes[0]?.language || '';
  4292. const withLineNumbers = exploreLineNumbersEnabled();
  4293. // Language-neutral separator between two non-contiguous slices of one file
  4294. // (no `//` — not a comment in Python, Ruby, etc.). With line numbers on,
  4295. // the line-number jump also signals the gap.
  4296. const GAP_MARKER = '\n\n... (gap) ...\n\n';
  4297. // Cross-call dedup (CG-18). `served` is what THIS session already sent the
  4298. // agent for THIS file, and it is empty unless the file still hashes to the
  4299. // bytes those spans were sliced from — an edit between calls means the
  4300. // agent's copy is wrong, so nothing is withheld. Every render path below
  4301. // routes its spans through `dedupeSpans`, which is the only place a span
  4302. // is ever dropped.
  4303. const fingerprint = fileFingerprint(fileContent);
  4304. const served = dedupEnabled ? servedRangesForFile(priorCalls, filePath, fingerprint) : [];
  4305. /** Render one line span exactly as the render paths do. */
  4306. const renderSpan = (r: ExploreLineRange): string => {
  4307. const slice = fileLines.slice(r.start - 1, r.end).join('\n');
  4308. return withLineNumbers ? numberSourceLines(slice, r.start) : slice;
  4309. };
  4310. /**
  4311. * Apply the session history to a set of spans-with-text. A span the agent
  4312. * already holds is dropped and reported in `covered`; a partially-held one
  4313. * is re-rendered down to its new lines. Text is rebuilt from the surviving
  4314. * spans rather than sliced out of the original string — the spans ARE the
  4315. * contract with the session record, so rebuilding from them is what keeps
  4316. * what we claim to have sent and what we sent the same thing.
  4317. */
  4318. const dedupeSpans = (
  4319. parts: ReadonlyArray<{ range: ExploreLineRange; text: string }>,
  4320. ): { parts: Array<{ range: ExploreLineRange; text: string }>; covered: ExploreLineRange[] } => {
  4321. if (served.length === 0) return { parts: [...parts], covered: [] };
  4322. const kept: Array<{ range: ExploreLineRange; text: string }> = [];
  4323. const covered: ExploreLineRange[] = [];
  4324. for (const part of parts) {
  4325. const split = dedupeRange(part.range, served);
  4326. if (split.covered.length === 0) {
  4327. kept.push(part);
  4328. continue;
  4329. }
  4330. covered.push(...split.covered);
  4331. for (const r of split.emit) kept.push({ range: r, text: renderSpan(r) });
  4332. }
  4333. return { parts: kept, covered: mergeRanges(covered) };
  4334. };
  4335. const coveredChars = (spans: ReadonlyArray<ExploreLineRange>): number =>
  4336. spans.reduce((sum, r) => sum + fileLines.slice(r.start - 1, r.end).join('\n').length, 0);
  4337. /**
  4338. * Emit one file's section — header, the back-reference for whatever the
  4339. * agent already holds, and the fence for what is new. Every render path
  4340. * ends here so that the dedup bookkeeping (freed bytes, freed `maxFiles`
  4341. * slot, the session record, the diagnostic) is written in exactly one
  4342. * place and no path can forget a piece of it.
  4343. *
  4344. * A fully-held file emits its header and pointer and NO fence, and
  4345. * deliberately does not consume a `maxFiles` slot: that is half of where
  4346. * the reclaimed budget goes (the other half is `sourceSpent`, which the
  4347. * carry-forward pool hands down the rank order). Both send bytes to files
  4348. * the agent has NOT seen, which is the whole point.
  4349. */
  4350. const emitFileSection = (opts: {
  4351. header: string;
  4352. /** Deduped source. Empty ⇒ the agent already holds all of it. */
  4353. body: string;
  4354. /** Spans `body` covers. */
  4355. ranges: ExploreLineRange[];
  4356. /** Spans replaced by the back-reference. */
  4357. covered: ExploreLineRange[];
  4358. /**
  4359. * Chars charged on top of the body by the ANTI-ABANDONMENT RESTORE path
  4360. * only (it re-splices a section after the loop and needs one number for
  4361. * it). The loop itself charges the real cost — see `sectionCost`.
  4362. */
  4363. overhead: number;
  4364. mode: 'whole' | 'clusters' | 'focused' | 'skeleton';
  4365. clipped: boolean;
  4366. /** The undeduped render, kept for the no-new-source fallback. */
  4367. fullBody: string;
  4368. fullRanges: ExploreLineRange[];
  4369. }): void => {
  4370. // A remainder too small to be worth a fence is folded into the pointer
  4371. // (see MIN_DELTA_CHARS). Its ranges are then NOT recorded — the record
  4372. // must only ever claim source that was actually sent.
  4373. const folded = opts.covered.length > 0 && opts.body.length < EXPLORE_DEDUP.MIN_DELTA_CHARS;
  4374. const body = folded ? '' : opts.body;
  4375. const ranges = folded ? [] : opts.ranges;
  4376. const at = lines.length;
  4377. lines.push(opts.header, '');
  4378. // Charge what the section ACTUALLY costs, not a flat 200 (CG-26). A
  4379. // header carries the path plus up to `maxSymbolsInFileHeader` symbol
  4380. // names and routinely runs 300–500 chars, so the flat charge made the
  4381. // loop believe it had room it did not have: okhttp rendered 26,601
  4382. // chars against a 24,400 ceiling and the final truncation threw a
  4383. // fully-rendered section away. Everything downstream is expressed in
  4384. // these units — `headroom`, `fundedHeadroom`, every fit test — so an
  4385. // under-count is not a rounding error, it funds a promise out of bytes
  4386. // that do not exist and starves whoever the loop reaches last.
  4387. totalChars += opts.header.length + 2;
  4388. if (opts.covered.length > 0) {
  4389. const pointer = formatBackReference(
  4390. filePath,
  4391. opts.covered,
  4392. symbolsInSpans(group.nodes, opts.covered),
  4393. { partial: body.length > 0 },
  4394. );
  4395. lines.push(pointer, '');
  4396. totalChars += pointer.length + 2;
  4397. backReferencedFiles.push(filePath);
  4398. }
  4399. if (body.length > 0) {
  4400. lines.push('```' + lang, body, '```', '');
  4401. // ```lang \n body \n ``` \n '' \n — exact, same as the header above.
  4402. totalChars += body.length + lang.length + 11;
  4403. sourceSpent += body.length;
  4404. newSourceChars += body.length;
  4405. diag?.recordRender(filePath, opts.mode, body.length, opts.clipped || opts.covered.length > 0);
  4406. if (opts.covered.length > 0) diag?.recordDedup(filePath, coveredChars(opts.covered), opts.covered);
  4407. noteEmitted(filePath, [...ranges, ...opts.covered], body.length, fingerprint);
  4408. renderedFilePaths.push(filePath);
  4409. filesIncluded++;
  4410. return;
  4411. }
  4412. // Fully held. The section is the pointer; the slot and the bytes go to a
  4413. // file the agent has not seen. The spans are still recorded (at zero
  4414. // bytes) because the record means "source the agent HAS", not "bytes
  4415. // this call spent" — refreshing them keeps a long session from ageing
  4416. // them out of the retained window and re-serving them for nothing.
  4417. // (The header is already charged above; a fully-held section is the
  4418. // header plus the pointer and nothing else.)
  4419. diag?.recordRender(filePath, 'backref', 0, false);
  4420. diag?.recordDedup(filePath, coveredChars(opts.covered), opts.covered);
  4421. noteEmitted(filePath, opts.covered, 0, fingerprint);
  4422. renderedFilePaths.push(filePath);
  4423. if (!suppressedFallback && opts.fullBody.length > 0) {
  4424. suppressedFallback = {
  4425. filePath,
  4426. at,
  4427. replacing: lines.length - at,
  4428. section: [opts.header, '', '```' + lang, opts.fullBody, '```', ''],
  4429. sourceChars: opts.fullBody.length,
  4430. overhead: opts.overhead,
  4431. ranges: opts.fullRanges,
  4432. fingerprint,
  4433. };
  4434. }
  4435. };
  4436. // Disk-drift gate (#1474): every render branch below except whole-file
  4437. // slices fileContent (CURRENT bytes) at INDEXED line ranges. Content is
  4438. // already in hand, so the check costs one stat (hash only on mismatch).
  4439. const fileStale = this.isFileStaleOnDisk(cg, filePath, fileContent);
  4440. // Adaptive sizing (CODEGRAPH_ADAPTIVE_EXPLORE, default on): collapse a file
  4441. // to a per-symbol view when it's a redundant member of a polymorphic family.
  4442. // Engages iff ALL hold:
  4443. // 1. a flow spine exists,
  4444. // 2. no symbol in the file is on that spine (it's not the mechanism path),
  4445. // 3. it IS a polymorphic sibling (≥ MIN_SIBLINGS impls of a shared supertype),
  4446. // 4. it is NOT SPARED, where a file is spared iff the agent named a
  4447. // (near-)UNIQUE callable in it (`getResponseWithInterceptorChain`, 1 def →
  4448. // keep RealCall.kt full) UNLESS the file DEFINES the family supertype (a
  4449. // base+subclasses "family" file like Django's compiler.py — collapse it).
  4450. // Uniqueness matters: `as_sql` has 110 defs across every Compiler/Expression
  4451. // subclass; naming it must NOT keep every backend variant + test file full
  4452. // and flood the budget. That's why the spare reads uniqueNamedNodeIds.
  4453. // Within a collapsed file the render is PER-SYMBOL (condition B): a method the
  4454. // agent NAMED or that's on the spine is shown with its FULL body (so the agent
  4455. // doesn't Read the file back for it — Django's SQLCompiler.execute_sql/as_sql);
  4456. // every other symbol is just its signature. So the base mechanism survives while
  4457. // the file's other ~80 symbols + the redundant subclasses collapse to one line each.
  4458. const spareNamed = group.nodes.some(n => flow.uniqueNamedNodeIds.has(n.id));
  4459. const fileDefinesSuper = definesPolymorphicSupertype(group.nodes);
  4460. const spared = spareNamed && !fileDefinesSuper;
  4461. const CALLABLE_BODY = new Set(['method', 'function', 'constructor', 'component']);
  4462. const hasSpineNode = group.nodes.some(n => flow.pathNodeIds.has(n.id));
  4463. // On-spine god-file: the flow path runs THROUGH this file, but it also holds
  4464. // many OTHER named methods, and rendering all of them in full blows the
  4465. // per-file budget and starves the other flow files (Alamofire: the agent
  4466. // names ~7 Session.swift methods — the build spine PLUS off-path
  4467. // task/didCompleteTask — far past the whole response budget). Engage the
  4468. // per-symbol view to keep the SPINE full and collapse the off-path named
  4469. // methods to signatures. Only when there IS off-path content to shed —
  4470. // otherwise the spine is irreducible (a sequential flow has no redundancy),
  4471. // so leave it to the normal full render.
  4472. const namedBodyChars = group.nodes
  4473. .filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
  4474. .reduce((s, n) => s + fileLines.slice(n.startLine - 1, n.endLine).join('\n').length, 0);
  4475. const onSpineGodFile = hasSpineNode
  4476. && namedBodyChars > allowance
  4477. && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
  4478. if (!fileStale && adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
  4479. && (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) {
  4480. const syms = group.nodes
  4481. .filter(n => n.kind !== 'import' && n.kind !== 'export' && n.startLine > 0)
  4482. .sort((a, b) => a.startLine - b.startLine);
  4483. // Pass 1: choose which symbols get a FULL body, by priority, greedily within
  4484. // a per-file body cap — so one huge family file can't body every named method
  4485. // and crowd out the other flow files (Django's query.py). A symbol earns a
  4486. // body if it's on-spine, or UNIQUELY named (`SQLCompiler.execute_sql`), or a
  4487. // co-named method WHEN this file DEFINES the family supertype (so the base
  4488. // `SQLCompiler.as_sql` body shows, but the 110 leaf `as_sql` overrides — and
  4489. // OkHttp's 5 `intercept`s if the agent names `intercept` — stay signatures).
  4490. const prio = (n: Node) => !CALLABLE_BODY.has(n.kind) ? 99
  4491. : flow.pathNodeIds.has(n.id) ? 0
  4492. : flow.uniqueNamedNodeIds.has(n.id) ? 1
  4493. : (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
  4494. // One WINDOW per file, sized by this file's RESERVATION. syms are taken by
  4495. // priority (spine first, then uniquely-named, then family-base), and the cap
  4496. // applies to ALL of them — including the spine — so a big-spine god-file
  4497. // (tokio's worker.rs: run→run_task→next_task→steal_work) can't eat the whole
  4498. // response and starve the co-flow file (harness.rs's poll). The native agent
  4499. // windows such a file too (~190 lines at a time), so this mimics, not
  4500. // truncates. Always emit ≥1 (never an empty section).
  4501. //
  4502. // Held to `fundedHeadroom` as well (CG-31) so this path cannot spend a
  4503. // reservation still owed below it either. It never exceeds `allowance`
  4504. // today, so the bound only bites once the ceiling is genuinely tight —
  4505. // but "every render path" has to mean every one, or the guard is just a
  4506. // detour the next god-file takes.
  4507. const bodyCap = Math.min(allowance, fundedHeadroom);
  4508. const bodyIds = new Set<string>();
  4509. let bodyChars = 0;
  4510. for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
  4511. const sz = fileLines.slice(n.startLine - 1, n.endLine).join('\n').length;
  4512. if (bodyChars + sz > bodyCap && bodyIds.size > 0) continue;
  4513. bodyIds.add(n.id);
  4514. bodyChars += sz;
  4515. }
  4516. // Pass 2: render in line order — full body for chosen symbols, else the
  4517. // signature line (capped, with a "+N more" tail so the structure map of a
  4518. // god-file doesn't itself bloat the budget).
  4519. const skel: Array<{ range: ExploreLineRange; text: string }> = [];
  4520. let coveredUntil = 0; // skip symbols already inside an emitted body
  4521. let sigCount = 0, sigDropped = 0;
  4522. const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
  4523. for (const n of syms) {
  4524. if (n.startLine <= coveredUntil) continue;
  4525. if (bodyIds.has(n.id)) {
  4526. const end = n.endLine;
  4527. const body = fileLines.slice(n.startLine - 1, end).join('\n');
  4528. skel.push({
  4529. range: { start: n.startLine, end },
  4530. text: withLineNumbers ? numberSourceLines(body, n.startLine) : body,
  4531. });
  4532. coveredUntil = end;
  4533. } else {
  4534. // Elide the body, emit the signature. node.startLine can point at a
  4535. // decorator/annotation, so scan forward for the line that names the symbol.
  4536. let lineNo = n.startLine;
  4537. for (let k = 0; k < 4; k++) {
  4538. if ((fileLines[n.startLine - 1 + k] || '').includes(n.name)) { lineNo = n.startLine + k; break; }
  4539. }
  4540. if (lineNo <= coveredUntil) continue;
  4541. if (sigCount >= SIG_MAX) { sigDropped++; continue; }
  4542. const sig = (fileLines[lineNo - 1] || '').trim();
  4543. if (sig) {
  4544. skel.push({
  4545. range: { start: lineNo, end: lineNo },
  4546. text: withLineNumbers ? `${lineNo}\t${sig}` : sig,
  4547. });
  4548. sigCount++;
  4549. }
  4550. }
  4551. }
  4552. const sigTail = sigDropped > 0 ? `… +${sigDropped} more (signatures elided)` : '';
  4553. if (skel.length > 0) {
  4554. const names = [...new Set(group.nodes.filter(n => n.kind !== 'import' && n.kind !== 'export').map(n => n.name))]
  4555. .slice(0, budget.maxSymbolsInFileHeader).join(', ');
  4556. // Steer the agent to codegraph_explore for an elided body — NEVER to
  4557. // Read. The old "Read for more" / "Read for a full body" tags invited
  4558. // a Read of the very file just skeletonized; on a central, wanted file
  4559. // (Session.swift, DataRequest.swift) that fired an over-investigation
  4560. // spiral (the agent Read the skeletonized file, then kept digging).
  4561. // CLAUDE.md: explore output must never tell the agent to Read.
  4562. const tag = bodyIds.size > 0
  4563. ? 'focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read)'
  4564. : 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
  4565. // Dedup runs on the per-symbol parts, so a body the agent already has
  4566. // becomes a pointer while the signature map around it survives intact
  4567. // (a one-line signature is far under MIN_COVERED_LINES and is never
  4568. // withheld — the structure map is what makes this render legible).
  4569. const dd = dedupeSpans(skel);
  4570. const withTail = (parts: ReadonlyArray<{ text: string }>) =>
  4571. [...parts.map((p) => p.text), ...(sigTail ? [sigTail] : [])].join('\n');
  4572. emitFileSection({
  4573. header: fileSectionHeader(filePath, `${names} · ${tag}`),
  4574. body: dd.parts.length > 0 ? withTail(dd.parts) : '',
  4575. ranges: dd.parts.map((p) => p.range),
  4576. covered: dd.covered,
  4577. overhead: 120,
  4578. mode: bodyIds.size > 0 ? 'focused' : 'skeleton',
  4579. // Always "clipped": the per-symbol view elides bodies by construction.
  4580. clipped: true,
  4581. fullBody: withTail(skel),
  4582. fullRanges: skel.map((p) => p.range),
  4583. });
  4584. continue;
  4585. }
  4586. }
  4587. // Whole-file rule: if a relevant file is small enough to afford, return it
  4588. // ENTIRELY instead of clustering. Clustering exists to tame god-files
  4589. // (App.tsx ~13k lines); on a ~134-line component a cluster is a lossy
  4590. // subset of a file the agent will just Read in full anyway — costing a
  4591. // round-trip and a re-read every later turn. Reserve clustering for files
  4592. // too big to ship whole. Still bounded by the total maxOutputChars check.
  4593. //
  4594. // CENTRAL files (where the query's entry points live) get a larger — but
  4595. // bounded — ceiling: they're the heart of the answer, the file(s) the agent
  4596. // would Read whole, so a genuinely small one comes back whole rather than as
  4597. // thin clusters. A LARGE central file (the 791-line org-user store) exceeds
  4598. // the ceiling and falls through to sectioning/clustering below — full method
  4599. // bodies + signatures — so we never dump (or overflow on) a whole god-file.
  4600. const isCentralFile = centralFiles.has(filePath);
  4601. // A file ships whole when it fits its RESERVATION (plus a small grace — see
  4602. // WHOLE_FILE_GRACE). This is the site of the #1500 allocation bug: the
  4603. // peripheral bound used to be a flat `maxCharsPerFile * 3`, so ANY file under
  4604. // ~11K shipped its entire contents regardless of relevance, while a
  4605. // high-scoring file too big for that window was clipped to `maxCharsPerFile`
  4606. // — a 3x swing decided by file size alone. Tying both bounds to the
  4607. // reservation removes the swing without touching the rule's purpose (a small
  4608. // file sliced is a lossy subset the agent just Reads in full anyway).
  4609. const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
  4610. // Two bounds, whichever is larger (CG-21):
  4611. // GRACE — the reservation plus a sliver, for a file that essentially fits;
  4612. // BUY — the reservation already covers most of the file, so the rest is
  4613. // cheaper to ship than to lose. A file between the two used to
  4614. // fall through to clustering and then spend a FRACTION of its
  4615. // reservation, and the remainder was neither delivered nor
  4616. // redistributed. See WHOLE_FILE_BUY_FRACTION.
  4617. //
  4618. // The BUY arm is two independent tests, and keeping them apart is the whole
  4619. // design. MERIT reads `reserved` — did THIS file's own relevance earn most
  4620. // of itself? — so borrowed slack can never promote a weak file to whole.
  4621. // FUNDING reads the shared overshoot pool, so the bytes exist to pay for it.
  4622. // Slack still reaches the file through `allowance`: it raises the GRACE arm
  4623. // and shrinks what a buy has to borrow.
  4624. const graceBound = allowance + Math.min(
  4625. EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_MAX,
  4626. Math.round(allowance * EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_FRACTION),
  4627. );
  4628. // FUNDING, as one inequality: after this file ships whole, does the source
  4629. // still fit the promise-plus-overshoot line WITH every reservation below
  4630. // it left payable? `owedBelow` is what makes it a displacement guard
  4631. // rather than a size cap — a buy that fits the line only by spending a
  4632. // lower-ranked file's reservation is the trade that dropped
  4633. // `payslip_builder.go`, and it is refused here. Self-limiting: each buy
  4634. // grows `sourceSpent`, so the pool cannot be spent twice. The cluster path
  4635. // below enforces the same inequality in render space — see
  4636. // `fundedHeadroom` / `owedPayableBelow` (CG-31).
  4637. const owedBelow = Math.max(0, reservedTotal - reservedSoFar);
  4638. // Third condition on the BUY arm only: it must also FIT. A whole render
  4639. // that overruns the ceiling is skipped ENTIRELY (the branch refuses to
  4640. // slice a file mid-method), so attempting a buy that cannot fit trades a
  4641. // clustered section for NO section — the same trade the funding pool
  4642. // exists to refuse, arriving by a different route. Failing the test here
  4643. // instead drops through to the cluster path, which is bounded by
  4644. // `fundedHeadroom` and always renders something.
  4645. //
  4646. // Measured against `fundedHeadroom`, not against `renderCeiling - totalChars`
  4647. // (CG-26). The two differ by exactly the displacement term: room before
  4648. // the ceiling belongs to every file the loop has not reached yet, and
  4649. // this arm used to read the raw room while its source-space sibling
  4650. // (`owedBelow`, above) refused the same trade. Source-space alone was not
  4651. // enough — the funding line is `reservedTotal + 0.15 * envelope` (~27.2K
  4652. // when a medium repo saturates) while the render ceiling is ~24.2K, so a
  4653. // buy can clear `sourceCeiling` and still take its bytes out of a
  4654. // lower-ranked file's reservation on the way to the ceiling. Now both
  4655. // arms enforce the same inequality in their own units, and the invariant
  4656. // holds on every path.
  4657. //
  4658. // The GRACE arm keeps its own bound (a file within a sliver of its
  4659. // reservation) but is fit-tested on the render it actually produces, at
  4660. // the emission site below, so it cannot displace either.
  4661. const buysWhole = fileContent.length <= graceBound
  4662. || (reserved >= fileContent.length * EXPLORE_ALLOCATION.WHOLE_FILE_BUY_FRACTION
  4663. && sourceSpent + fileContent.length + owedBelow <= sourceCeiling
  4664. && fileContent.length <= fundedHeadroom);
  4665. // Set by the whole-file arm when it actually emits. A whole render that
  4666. // does not FIT no longer ends the file's turn (CG-26) — it falls through
  4667. // to the cluster path below, which is bounded by `fundedHeadroom` and
  4668. // renders something. Skipping outright was the trade the funding pool
  4669. // exists to refuse: a clustered section traded for no section at all.
  4670. let renderedWhole = false;
  4671. if (fileLines.length <= WHOLE_FILE_MAX_LINES && buysWhole) {
  4672. const body = fileContent.replace(/\n+$/, '');
  4673. const wholeRange: ExploreLineRange = { start: 1, end: body.split('\n').length };
  4674. const fullSection = withLineNumbers ? numberSourceLines(body, 1) : body;
  4675. // The buy decision above was made on the file's FULL size on purpose: it
  4676. // asks "did this file's relevance earn all of itself", which dedup does
  4677. // not change. Dedup then only ever makes the render smaller, so a buy
  4678. // that was funded stays funded.
  4679. const ddWhole = dedupeSpans([{ range: wholeRange, text: fullSection }]);
  4680. const wholeSection = ddWhole.parts.map((p) => p.text).join(GAP_MARKER);
  4681. const uniqSymbols = [...new Set(
  4682. group.nodes
  4683. .filter(n => n.kind !== 'import' && n.kind !== 'export')
  4684. .map(n => `${n.name}(${n.kind})`)
  4685. )];
  4686. const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
  4687. const omitted = uniqSymbols.length - headerNames.length;
  4688. // A drifted file rendered WHOLE is still correct (current bytes,
  4689. // numbered from 1) — only the index-derived symbol list / line refs to
  4690. // it elsewhere in this response may be shifted (#1474). Flag that.
  4691. const staleSuffix = fileStale ? ' · ⚠ changed since last index sync — source below is current; the symbol list may be outdated' : '';
  4692. const wholeHeader = fileSectionHeader(filePath, (omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')) + staleSuffix);
  4693. // The fit test, on the bytes this render ACTUALLY costs (the numbered
  4694. // body, after dedup) rather than on the raw file — and against
  4695. // `fundedHeadroom`, so a whole render can no more spend a pending
  4696. // file's reservation than a clustered one can (CG-26). Both whole-file
  4697. // arms come through here, which is what closes the invariant on the
  4698. // GRACE path: grace is measured against this file's own allowance and
  4699. // says nothing about whether the bytes are still there to spend.
  4700. // Two tests, and they are different questions. `fundedHeadroom` is the
  4701. // DISPLACEMENT bound — may these bytes be spent without taking a
  4702. // pending file's reservation. `sectionCost` is the CEILING bound — do
  4703. // the header, fences and body actually fit what is left. The second one
  4704. // is exact now that the loop charges real section costs.
  4705. const wholeCost = wholeHeader.length + 2 + wholeSection.length + lang.length + 11;
  4706. if (wholeSection.length <= fundedHeadroom && totalChars + wholeCost <= renderCeiling) {
  4707. emitFileSection({
  4708. header: wholeHeader,
  4709. body: wholeSection,
  4710. // The whole file, minus any trailing blank lines the render trimmed.
  4711. ranges: ddWhole.parts.map((p) => p.range),
  4712. covered: ddWhole.covered,
  4713. overhead: 200,
  4714. mode: 'whole',
  4715. clipped: false,
  4716. fullBody: fullSection,
  4717. fullRanges: [wholeRange],
  4718. });
  4719. if (fileStale) staleRendered.push(filePath);
  4720. renderedWhole = true;
  4721. } else {
  4722. // Doesn't fit whole — don't slice a whole file mid-method here; fall
  4723. // through and let the cluster path pick body-shaped pieces of it.
  4724. anyFileTrimmed = true;
  4725. }
  4726. }
  4727. if (renderedWhole) continue;
  4728. // Drifted file too big for the whole-file window (#1474): the cluster /
  4729. // skeleton renders below would slice current bytes at indexed ranges —
  4730. // on a shifted file that serves a DIFFERENT symbol's code under the
  4731. // requested name. Omit the source with an explicit notice instead;
  4732. // never render a possibly-wrong slice.
  4733. if (fileStale) {
  4734. staleOmitted.push(filePath);
  4735. const staleHeader = fileSectionHeader(filePath, '⚠ changed on disk after the last index sync — source omitted (indexed line ranges no longer match, so a slice could show the wrong code). Read this file directly for current content; the change is picked up on that project\'s next index sync.');
  4736. lines.push(staleHeader, '');
  4737. totalChars += staleHeader.length + 2;
  4738. diag?.recordRender(filePath, 'stale-omitted', 0, true);
  4739. continue;
  4740. }
  4741. // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
  4742. // Sort by start line, then merge overlapping/adjacent ranges (within the
  4743. // adaptive gap threshold). Include both node ranges AND edge source
  4744. // locations so template sections with component usages/calls are
  4745. // covered (not just script block symbols).
  4746. //
  4747. // Each range carries an `importance` score so we can rank clusters
  4748. // when the per-file budget forces us to drop some: entry-point nodes
  4749. // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
  4750. // bare edge-source lines 2 (less than a connected node but more than
  4751. // a peripheral one — they hint at a reference but aren't a definition).
  4752. // Container kinds whose body can span most/all of a file. When such a
  4753. // node covers most of the file we drop it from the ranges: keeping it
  4754. // would merge every method inside it into one giant cluster spanning
  4755. // the whole file, which then tail-trims down to just the container's
  4756. // opening lines (its header/declarations) and buries the methods the
  4757. // query actually asked about (#185 follow-up — Session.swift in
  4758. // Alamofire is the canonical case: the `Session` class spans ~1,400
  4759. // lines). We want the granular symbols inside, not the envelope.
  4760. const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'union', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
  4761. // Cluster from this file's gathered nodes PLUS any callable the agent NAMED that
  4762. // lives here. Explore's relevance gather can miss a named method def in a huge
  4763. // non-sibling file — Django's query.py is 3,040 lines and `_fetch_all` (L2237)
  4764. // was gathered only as call-reference edges, never as a def, so it formed no
  4765. // cluster and the agent Read it back. Inject named defs directly and rank them
  4766. // ABOVE connected/glue nodes (importance 9) so their cluster wins the per-file
  4767. // budget — the agent explicitly asked for these symbols.
  4768. const rangeNodes = new Map<string, Node>();
  4769. for (const n of group.nodes) if (n.startLine > 0 && n.endLine > 0) rangeNodes.set(n.id, n);
  4770. for (const id of flow.namedNodeIds) {
  4771. if (rangeNodes.has(id)) continue;
  4772. const n = cg.getNode(id);
  4773. if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0) rangeNodes.set(id, n);
  4774. }
  4775. const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineCallLine?: number }> = [...rangeNodes.values()]
  4776. // Drop whole-file envelope nodes (containers covering >50% of the file).
  4777. .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
  4778. .map(n => {
  4779. let importance = 1;
  4780. if (entryNodeIds.has(n.id)) importance = 10;
  4781. else if (flow.namedNodeIds.has(n.id)) importance = 9; // agent named it → keep its cluster
  4782. else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
  4783. else if (connectedToEntry.has(n.id)) importance = 3;
  4784. // On the rendered call-path spine? That IS the flow answer — its cluster
  4785. // must never be dropped by the per-file budget (n8n's huge workflow-execute.ts:
  4786. // processRunExecutionData, the named flow ENTRY at L1562, is a large
  4787. // low-density method that lost the budget to denser blocks and got cut, so
  4788. // the agent Read it back — the very thing explore exists to prevent).
  4789. return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) };
  4790. });
  4791. // Add edge source locations in this file — captures template references
  4792. // (component usages, event handlers) that aren't nodes themselves.
  4793. // Query edges directly from the DB (not just the subgraph) because BFS
  4794. // traversal may have pruned template reference targets due to node budget.
  4795. const edgeLines = new Set<string>(); // dedup by "line:name"
  4796. for (const node of group.nodes) {
  4797. const outgoing = cg.getOutgoingEdges(node.id);
  4798. for (const edge of outgoing) {
  4799. if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
  4800. const key = `${edge.line}:${edge.target}`;
  4801. if (edgeLines.has(key)) continue;
  4802. edgeLines.add(key);
  4803. // Look up target name from subgraph first, fall back to edge kind
  4804. const targetNode = subgraph.nodes.get(edge.target);
  4805. const targetName = targetNode?.name ?? edge.kind;
  4806. ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
  4807. }
  4808. }
  4809. ranges.sort((a, b) => a.start - b.start);
  4810. if (ranges.length === 0) {
  4811. diag?.recordSkip(filePath, 'no-ranges');
  4812. continue;
  4813. }
  4814. const gapThreshold = budget.gapThreshold;
  4815. type ExploreRange = typeof ranges[number];
  4816. type ExploreCluster = {
  4817. start: number; end: number; symbols: string[]; score: number;
  4818. maxImportance: number; hasSpine: boolean; spineCallLine?: number;
  4819. /** The whole symbol ranges this cluster merged — the unit an oversize
  4820. * cluster is shrunk by, so shrinking never cuts through a body. */
  4821. members: ExploreRange[];
  4822. };
  4823. const clusters: ExploreCluster[] = [];
  4824. let current: ExploreCluster = {
  4825. start: ranges[0]!.start,
  4826. end: ranges[0]!.end,
  4827. symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
  4828. score: ranges[0]!.importance,
  4829. maxImportance: ranges[0]!.importance,
  4830. hasSpine: ranges[0]!.spine,
  4831. spineCallLine: ranges[0]!.spineCallLine,
  4832. members: [ranges[0]!],
  4833. };
  4834. for (let i = 1; i < ranges.length; i++) {
  4835. const r = ranges[i]!;
  4836. if (r.start <= current.end + gapThreshold) {
  4837. current.end = Math.max(current.end, r.end);
  4838. current.symbols.push(`${r.name}(${r.kind})`);
  4839. current.score += r.importance;
  4840. current.maxImportance = Math.max(current.maxImportance, r.importance);
  4841. current.hasSpine = current.hasSpine || r.spine;
  4842. current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
  4843. current.members.push(r);
  4844. } else {
  4845. clusters.push(current);
  4846. current = {
  4847. start: r.start,
  4848. end: r.end,
  4849. symbols: [`${r.name}(${r.kind})`],
  4850. score: r.importance,
  4851. maxImportance: r.importance,
  4852. hasSpine: r.spine,
  4853. spineCallLine: r.spineCallLine,
  4854. members: [r],
  4855. };
  4856. }
  4857. }
  4858. clusters.push(current);
  4859. // Build file section output from clusters, capped by per-file budget.
  4860. // The pathological case (#185): a file like Session.swift where every
  4861. // method is adjacent collapses into one cluster spanning the whole
  4862. // file, and dumping that into the agent's context is most of the
  4863. // token cost on small projects. We pick clusters in priority order
  4864. // until the per-file char cap is hit. Truly enormous single clusters
  4865. // get tail-trimmed with a marker.
  4866. const contextPadding = 3;
  4867. // An oversize spine method (the call path runs THROUGH a god-method — n8n's
  4868. // processRunExecutionData is 962 lines) is windowed to its next-hop CALL site
  4869. // plus the signature head, NOT dumped whole. Without this the cluster is too big
  4870. // for any per-file cap and gets dropped, so the agent Reads the method back —
  4871. // the exact gap this closes. Bounded, so a god-method can't blow the budget yet
  4872. // the spine's call still appears in context.
  4873. const OVERSIZE_SPINE_LINES = 200;
  4874. const SPINE_WINDOW = 28; // lines each side of the next-hop call site
  4875. // Returns the rendered text as SPAN-KEYED PARTS. Every part carries the
  4876. // exact line range its text was sliced from, which two things depend on:
  4877. // the session record (CG-17) — a record claiming lines it never sent would
  4878. // withhold them from a later call, costing a Read — and cross-call dedup
  4879. // (CG-18), which rebuilds a part's text from a narrower span when the
  4880. // agent already holds the rest. Both read the spans from the function that
  4881. // does the slicing; a second function mirroring these window/padding rules
  4882. // would drift.
  4883. type SectionPart = { range: ExploreLineRange; text: string };
  4884. const sectionText = (parts: ReadonlyArray<SectionPart>): string =>
  4885. parts.map((p) => p.text).join(GAP_MARKER);
  4886. const buildSection = (
  4887. c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number },
  4888. ): SectionPart[] => {
  4889. if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
  4890. const call = c.spineCallLine;
  4891. const winStart = Math.max(c.start, call - SPINE_WINDOW);
  4892. const winEnd = Math.min(c.end, call + SPINE_WINDOW);
  4893. const parts: SectionPart[] = [];
  4894. // Signature head, only when it sits clearly above the window (else the
  4895. // window already covers the method opening).
  4896. const headEnd = Math.min(c.start + 4, winStart - 2);
  4897. if (headEnd >= c.start) {
  4898. const head = fileLines.slice(c.start - 1, headEnd).join('\n');
  4899. parts.push({
  4900. range: { start: c.start, end: headEnd },
  4901. text: withLineNumbers ? numberSourceLines(head, c.start) : head,
  4902. });
  4903. }
  4904. const win = fileLines.slice(winStart - 1, winEnd).join('\n');
  4905. parts.push({
  4906. range: { start: winStart, end: winEnd },
  4907. text: withLineNumbers ? numberSourceLines(win, winStart) : win,
  4908. });
  4909. return parts;
  4910. }
  4911. const startIdx = Math.max(0, c.start - 1 - contextPadding);
  4912. const endIdx = Math.min(fileLines.length, c.end + contextPadding);
  4913. const slice = fileLines.slice(startIdx, endIdx).join('\n');
  4914. // startIdx is 0-based, so the slice's first line is line startIdx + 1.
  4915. return [{
  4916. range: { start: startIdx + 1, end: endIdx },
  4917. text: withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice,
  4918. }];
  4919. };
  4920. /**
  4921. * Shrink an oversize cluster to the highest-importance symbols inside it
  4922. * that fit `cap`, rendered in source order with gap markers (CG-12).
  4923. *
  4924. * A cluster is a MERGE of whole symbol ranges, and on a densely-packed file
  4925. * every symbol merges into one blob spanning the file — cycle.go's 209-line
  4926. * `Service` is one cluster covering `RunCycle`, `runPayrollCycleAll` and
  4927. * seven incidental accessors. The old rule took the top-ranked cluster whole
  4928. * however big it was, so a single-cluster file simply ignored its budget:
  4929. * it took ~40% more than it was allotted, and the file below it was then
  4930. * dropped for lack of room (that is how `BuildPayslip` — the "calculate"
  4931. * half of the #1500 query — went missing entirely). Shrinking by MEMBER
  4932. * keeps every rule that matters: only whole symbol ranges are emitted, so a
  4933. * body is never cut, and the members are chosen by the same importance the
  4934. * cluster ranking uses. Returns null when nothing needed shrinking.
  4935. *
  4936. * `sizeOf` measures the RAW source span, while the render adds
  4937. * `contextPadding` around every block and a line-number prefix to every
  4938. * line — so this over-keeps (measured ~60% under on a 1,414-line file:
  4939. * 16.5K accounted, 26.3K rendered). That is deliberate, not an oversight:
  4940. * `bound()` clamps the result to the ceiling exactly, so the slack costs no
  4941. * bytes, and making the estimate exact instead measured WORSE — it stops at
  4942. * the last member that fits whole, and the released bytes carry forward to
  4943. * lower-ranked files (payroll-go's `runPayrollCycleAll` body lost its
  4944. * `s.store.Upsert` call to a rank-5 file). What the slack must NOT do is
  4945. * decide WHICH members survive: that is the ceiling trim's job, and CG-38 is
  4946. * why that trim now protects the named spans instead of cutting in source
  4947. * order. See `docs/benchmarks/explore-tail-render-cg38.md`.
  4948. */
  4949. const shrinkCluster = (c: ExploreCluster, cap: number): SectionPart[] | null => {
  4950. if (c.members.length < 2) return null;
  4951. const byImportance = [...c.members].sort((a, b) =>
  4952. b.importance - a.importance || (a.end - a.start) - (b.end - b.start) || a.start - b.start);
  4953. const sizeOf = (r: ExploreRange) => fileLines.slice(r.start - 1, r.end).join('\n').length;
  4954. const keep: ExploreRange[] = [];
  4955. let kept = 0;
  4956. for (const r of byImportance) {
  4957. const sz = sizeOf(r) + GAP_MARKER.length;
  4958. // Always keep the most important range, even if it alone is oversize —
  4959. // an empty section sends the agent to Read, which costs far more. How
  4960. // far it may overshoot is bounded by the caller's ceiling (CG-30), which
  4961. // windows a runaway member instead of dropping it.
  4962. if (keep.length > 0 && kept + sz > cap) continue;
  4963. keep.push(r);
  4964. kept += sz;
  4965. }
  4966. if (keep.length === c.members.length) return null;
  4967. // Re-merge the kept ranges in source order so adjacent survivors read as
  4968. // one block rather than a stutter of one-symbol fragments.
  4969. keep.sort((a, b) => a.start - b.start);
  4970. const merged: Array<{ start: number; end: number }> = [];
  4971. for (const r of keep) {
  4972. const last = merged[merged.length - 1];
  4973. if (last && r.start <= last.end + gapThreshold) last.end = Math.max(last.end, r.end);
  4974. else merged.push({ start: r.start, end: r.end });
  4975. }
  4976. return merged.flatMap((m) => buildSection(m));
  4977. };
  4978. /**
  4979. * Bounded overshoot for one cluster's render (CG-30).
  4980. *
  4981. * `shrinkCluster` keeps the highest-importance member whole even when that
  4982. * member alone is oversize — an empty file section sends the agent to Read,
  4983. * which is exactly what explore exists to prevent. But "never empty" is not
  4984. * "any size": with nothing bounding it, one 22K member rendered against a
  4985. * 9K reservation (2.4x), which collapses the headroom every file ranked
  4986. * below it draws from. Past the ceiling the member is WINDOWED rather than
  4987. * dropped — a leading window (signature + head of the body), plus a window
  4988. * on the spine's call site when the head misses it, since on a flow cluster
  4989. * the call path IS the answer.
  4990. */
  4991. const MIN_WINDOW_LINES = 12;
  4992. /** Rendered cost of one source line, line numbering included. */
  4993. const lineCost = (ln: number): number =>
  4994. (fileLines[ln - 1] ?? '').length + 1 + (withLineNumbers ? String(ln).length + 1 : 0);
  4995. /**
  4996. * Longest prefix of `r` that fits `room`. `minLines` is the never-empty
  4997. * floor — it may overrun `room`, so it is only ever asked for when nothing
  4998. * else has been emitted and the alternative is an empty section.
  4999. */
  5000. const headWindowOf = (
  5001. r: ExploreLineRange, room: number, minLines = 0,
  5002. ): ExploreLineRange | null => {
  5003. let end = r.start - 1;
  5004. let chars = 0;
  5005. for (let ln = r.start; ln <= r.end; ln++) {
  5006. const cost = lineCost(ln);
  5007. if (chars + cost > room && end - r.start + 1 >= minLines) break;
  5008. chars += cost;
  5009. end = ln;
  5010. }
  5011. return end >= r.start ? { start: r.start, end } : null;
  5012. };
  5013. /** Widest window around `line` inside [lo, hi] that fits `room`. */
  5014. const centeredWindowOf = (
  5015. line: number, lo: number, hi: number, room: number,
  5016. ): ExploreLineRange | null => {
  5017. if (line < lo || line > hi) return null;
  5018. let start = line, end = line, chars = lineCost(line);
  5019. for (let grown = true; grown;) {
  5020. grown = false;
  5021. if (end + 1 <= hi && chars + lineCost(end + 1) <= room) { end += 1; chars += lineCost(end); grown = true; }
  5022. if (start - 1 >= lo && chars + lineCost(start - 1) <= room) { start -= 1; chars += lineCost(start); grown = true; }
  5023. }
  5024. return { start, end };
  5025. };
  5026. /**
  5027. * Reduce rendered parts to fit `ceiling`, never to nothing. Whole parts are
  5028. * kept while they fit; the first part that overruns is cut to a leading
  5029. * window on whole lines (a body is never cut mid-line), and everything past
  5030. * it is dropped. The GAP_MARKER between surviving parts — and the line-number
  5031. * jump — is what tells the agent the cut happened.
  5032. *
  5033. * A partial window shorter than MIN_WINDOW_LINES is not worth emitting, and
  5034. * emitting one is actively harmful: the session record then claims a 4-line
  5035. * sliver, and the NEXT call's dedup has to either shred a whole block around
  5036. * it or re-send it. Below that floor the part is simply dropped — unless
  5037. * nothing has been emitted at all, where the floor wins over the ceiling
  5038. * because an empty section is the one outcome worse than an oversize one.
  5039. *
  5040. * `focusLines` are the lines this trim must not lose: the spine's next-hop
  5041. * call site (CG-30) and every definition the agent NAMED inside the cluster
  5042. * (CG-38). The head fill is source-ordered, so a named def in the TAIL of a
  5043. * large file is otherwise always the first thing an over-ceiling render
  5044. * drops — the one span the agent asked for by name, cut in favour of
  5045. * head-of-file filler it did not ask for. The full-ceiling fill is tried
  5046. * FIRST and the 60% hold-back applies only when a focus line is actually
  5047. * left uncovered, so a cluster whose head already reaches its focus keeps
  5048. * the whole ceiling for source.
  5049. */
  5050. const windowToCeiling = (
  5051. parts: ReadonlyArray<SectionPart>,
  5052. ceiling: number,
  5053. focusLines: ReadonlyArray<number> = [],
  5054. ): SectionPart[] => {
  5055. const inParts = (line: number) =>
  5056. parts.some((p) => line >= p.range.start && line <= p.range.end);
  5057. const focus = [...new Set(focusLines)]
  5058. .filter((l) => typeof l === 'number' && l > 0 && inParts(l))
  5059. .sort((a, b) => a - b);
  5060. /** Source-ordered fill of whole parts, the overrunning one cut to a head window. */
  5061. const fill = (room: number): { emit: ExploreLineRange[]; used: number } => {
  5062. const emit: ExploreLineRange[] = [];
  5063. let used = 0;
  5064. for (const p of parts) {
  5065. const join = emit.length > 0 ? GAP_MARKER.length : 0;
  5066. if (used + join + p.text.length <= room) {
  5067. emit.push(p.range);
  5068. used += join + p.text.length;
  5069. continue;
  5070. }
  5071. const first = emit.length === 0;
  5072. const win = headWindowOf(
  5073. p.range, Math.max(0, room - used - join), first ? MIN_WINDOW_LINES : 0);
  5074. if (win && (first || win.end - win.start + 1 >= MIN_WINDOW_LINES)) {
  5075. emit.push(win);
  5076. used += join + renderSpan(win).length;
  5077. }
  5078. break;
  5079. }
  5080. return { emit, used };
  5081. };
  5082. let { emit, used } = fill(ceiling);
  5083. const reached = () => (emit.length ? emit[emit.length - 1]!.end : 0);
  5084. if (focus.some((l) => l > reached())) {
  5085. // Hold room back for the focus windows so the head can't eat all of it.
  5086. ({ emit, used } = fill(Math.floor(ceiling * 0.6)));
  5087. }
  5088. // What is left is SPLIT between the uncovered focus lines rather than
  5089. // handed to them in order. Greedy-in-source-order reproduces the very bug
  5090. // this guards: on a prose query resolving four focus lines, the two
  5091. // earliest took the whole reserve and `flushQueuedMessages` at L1102 —
  5092. // named in the question — was dropped again. A skipped or undersized
  5093. // window returns its share to the pool for the ones after it.
  5094. let covered = reached();
  5095. let room = Math.max(0, ceiling - used);
  5096. const pending = focus.filter((l) => l > covered);
  5097. for (let i = 0; i < pending.length; i++) {
  5098. const line = pending[i]!;
  5099. if (line <= covered) continue; // an earlier window already reached it
  5100. const share = Math.floor(room / (pending.length - i)) - GAP_MARKER.length;
  5101. if (share <= 0) continue;
  5102. const host = parts.find((p) => line >= p.range.start && line <= p.range.end)!;
  5103. const lo = Math.max(host.range.start, line - SPINE_WINDOW, covered + 1);
  5104. const hi = Math.min(host.range.end, line + SPINE_WINDOW);
  5105. const win = centeredWindowOf(line, lo, hi, share);
  5106. // Same sliver floor as the head window — a two-line peek at the call
  5107. // site teaches the next call's dedup to shred the block around it.
  5108. if (!win || win.end - win.start + 1 < MIN_WINDOW_LINES) continue;
  5109. emit.push(win);
  5110. const cost = GAP_MARKER.length + renderSpan(win).length;
  5111. used += cost;
  5112. room -= cost;
  5113. covered = win.end;
  5114. }
  5115. // Never empty: a section with no source sends the agent to Read.
  5116. if (emit.length === 0 && parts.length > 0) {
  5117. const first = headWindowOf(parts[0]!.range, ceiling, MIN_WINDOW_LINES);
  5118. if (first) emit.push(first);
  5119. }
  5120. return emit
  5121. .sort((a, b) => a.start - b.start)
  5122. .map((r) => ({ range: r, text: renderSpan(r) }));
  5123. };
  5124. /**
  5125. * The lines a ceiling trim of this cluster must not lose: the spine's
  5126. * next-hop call site, and the definition line of every member the agent
  5127. * NAMED or that is a query entry point (importance >= 9). Capped, because
  5128. * each one costs a window and too many turn a section into confetti; the
  5129. * most important come first, source order within a tier so the windows read
  5130. * top-down.
  5131. */
  5132. const MAX_FOCUS_LINES = 6;
  5133. const focusLinesOf = (c: ExploreCluster): number[] => {
  5134. const named = c.members
  5135. .filter((m) => m.importance >= 9)
  5136. .sort((a, b) => b.importance - a.importance || a.start - b.start)
  5137. .slice(0, MAX_FOCUS_LINES)
  5138. .map((m) => m.start);
  5139. return c.spineCallLine ? [c.spineCallLine, ...named] : named;
  5140. };
  5141. /**
  5142. * One cluster's final parts: built, shrunk if it overruns `cap`, then
  5143. * passed through the session history (CG-18).
  5144. *
  5145. * The shrink decision reads the DEDUPED length on purpose. A cluster whose
  5146. * bytes the agent already holds costs this response nothing, so shrinking
  5147. * it on its raw size would drop new symbols to make room for source that
  5148. * is not being sent — spending the file's budget on nothing.
  5149. */
  5150. const renderCluster = (
  5151. c: ExploreCluster,
  5152. cap: number,
  5153. /**
  5154. * Hard bound on the rendered result (CG-30). `cap` is what selection asks
  5155. * for; this is how far a single oversize member is allowed to overshoot it
  5156. * before being windowed. Always >= `cap`, so a cluster that already fits is
  5157. * never touched.
  5158. */
  5159. ceiling: number = Infinity,
  5160. ): { parts: SectionPart[]; covered: ExploreLineRange[]; shrunk: boolean } => {
  5161. const base = dedupeSpans(buildSection(c));
  5162. const bound = (
  5163. r: { parts: SectionPart[]; covered: ExploreLineRange[]; shrunk: boolean },
  5164. ) => {
  5165. if (!Number.isFinite(ceiling) || sectionText(r.parts).length <= ceiling) return r;
  5166. // Windows are subsets of spans dedupeSpans already cleared, so the record
  5167. // still only ever claims source that was actually sent.
  5168. const parts = windowToCeiling(r.parts, ceiling, focusLinesOf(c));
  5169. return { parts, covered: r.covered, shrunk: true };
  5170. };
  5171. if (sectionText(base.parts).length <= cap) {
  5172. return { parts: base.parts, covered: base.covered, shrunk: false };
  5173. }
  5174. const shrunk = shrinkCluster(c, cap);
  5175. if (shrunk === null) {
  5176. return bound({ parts: base.parts, covered: base.covered, shrunk: false });
  5177. }
  5178. const dd = dedupeSpans(shrunk);
  5179. return bound({ parts: dd.parts, covered: dd.covered, shrunk: true });
  5180. };
  5181. // Rank clusters for inclusion under the per-file cap. Entry-point
  5182. // clusters come first: a cluster containing a query entry point
  5183. // (importance 10) must outrank a dense block of mere declarations,
  5184. // otherwise on a large file like Session.swift the top-of-file class
  5185. // header + property list (many adjacent low-importance nodes, high
  5186. // density) wins the budget and buries the actual methods the query
  5187. // asked about (perform/didCreateURLRequest/task live deep in the
  5188. // file). Within the same importance tier, prefer density (score per
  5189. // line) so we still favor focused clusters over sprawling ones, then
  5190. // smaller span as a cheap-to-include tiebreak.
  5191. const rankedClusters = clusters
  5192. .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
  5193. .sort((a, b) => {
  5194. // Spine clusters first — the rendered call path IS the flow answer, so it
  5195. // outranks any denser block of peripheral declarations (a low-density entry
  5196. // method must not lose the budget to them). Within spine / within non-spine,
  5197. // the existing importance → density → score → span order holds.
  5198. if (a.c.hasSpine !== b.c.hasSpine) return (b.c.hasSpine ? 1 : 0) - (a.c.hasSpine ? 1 : 0);
  5199. if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
  5200. const densityA = a.c.score / a.span;
  5201. const densityB = b.c.score / b.span;
  5202. if (densityB !== densityA) return densityB - densityA;
  5203. if (b.c.score !== a.c.score) return b.c.score - a.c.score;
  5204. return a.span - b.span;
  5205. });
  5206. // Per-file budget is this file's RESERVATION, bounded by what's left before
  5207. // the hard ceiling — so selection (which ranks by importance) keeps the
  5208. // high-importance clusters and drops peripheral ones, instead of the
  5209. // downstream source-order trim slicing off whatever comes last in the file.
  5210. // That source-order slice is what cut Django's `_fetch_all` (L2237, importance
  5211. // 9 — agent-named) when query.py was the last of four big files to be emitted.
  5212. // It used to be `min(maxCharsPerFile, remaining)`: a flat cap that clipped the
  5213. // top-scoring file at the same 3,800 as the weakest one, while the whole-file
  5214. // branch above handed a small file 3x that. The reservation is the whole point
  5215. // of CG-12 — bytes follow relevance, not file size.
  5216. //
  5217. // `fundedHeadroom`, not `headroom` (CG-31): what is left before the hard
  5218. // ceiling includes every unreached file's reservation, and spending that
  5219. // is how one clustered file zeroed five admitted peers. It is ≤ `headroom`
  5220. // by construction, so it is the only bound these three lines need.
  5221. const fileBudget = Math.min(allowance, fundedHeadroom);
  5222. // Spine ceiling: a flow-path cluster may exceed the reservation (the call path
  5223. // IS the answer and clipping it forces the Read), but bounded — 1.5x the
  5224. // reservation and never past the ceiling — so a pathological long in-file
  5225. // spine can't run away or starve co-flow files entirely. The 1.5x is drawn
  5226. // from the shared envelope, so it is exactly the overshoot the displacement
  5227. // guard has to fund: past `fundedHeadroom` the extra half-reservation is
  5228. // another file's, not spare room.
  5229. const SPINE_CEILING = Math.min(Math.round(allowance * 1.5), fundedHeadroom);
  5230. const chosenIndices = new Set<number>();
  5231. // Final renders (deduped, shrunk where oversize) by cluster index. Computed
  5232. // during selection and reused at emission so the two never disagree.
  5233. const renderedClusters = new Map<number, ReturnType<typeof renderCluster>>();
  5234. let anyClusterShrunk = false;
  5235. let projectedChars = 0;
  5236. for (const rc of rankedClusters) {
  5237. // The top-ranked cluster is always taken — an empty file section sends the
  5238. // agent to Read, negating the savings. But "always taken" is not "taken at
  5239. // any size": when it overruns the reservation it is SHRUNK to the
  5240. // highest-importance whole symbol ranges inside it, so a single-cluster
  5241. // god-file spends its allotment instead of the whole response's.
  5242. const first = chosenIndices.size === 0;
  5243. // A spine cluster (the rendered call path) is the flow answer — it may run
  5244. // past the per-file budget up to the spine ceiling; non-spine clusters obey
  5245. // the normal per-file budget.
  5246. const cap = rc.c.hasSpine ? SPINE_CEILING : fileBudget;
  5247. // CG-30: shrinking keeps the top member whole however big it is, so bound
  5248. // how far that member may overshoot — the same 1.5x-of-reservation bound
  5249. // SPINE_CEILING already draws, never below `cap` (a cluster that fits its
  5250. // cap is never windowed). A spine cluster's cap already IS that bound, so
  5251. // this holds it to it rather than letting the member rule walk past it.
  5252. const ceiling = Math.max(cap, SPINE_CEILING);
  5253. if (first) {
  5254. const section = renderCluster(rc.c, cap, ceiling);
  5255. renderedClusters.set(rc.idx, section);
  5256. anyClusterShrunk = anyClusterShrunk || section.shrunk;
  5257. chosenIndices.add(rc.idx);
  5258. projectedChars += sectionText(section.parts).length;
  5259. continue;
  5260. }
  5261. // Later clusters used to be all-or-nothing: rendered whole, then taken
  5262. // only if the whole thing fit the remainder. On a file whose top-ranked
  5263. // cluster is TRIVIAL that discards the answer and leaves the reservation
  5264. // unspent — django's `sql/query.py` keeps a 22-line glue cluster (one
  5265. // importance-6 bridging symbol) and drops the 624-line `Query` body
  5266. // beneath it whole, spending 1,923 of 7,947; the slack then carries
  5267. // forward to a file scoring a fifth as much (CG-36). Same shape in
  5268. // okhttp's `RealInterceptorChain.kt`, where an import header displaces
  5269. // the chain itself.
  5270. //
  5271. // So a later cluster is shrunk INTO the remainder by the same whole-member
  5272. // rule the first one already uses — CG-26's between-FILES lesson ("hold the
  5273. // remainder while it is still worth a section; zeroing it delivers
  5274. // nothing") applied between CLUSTERS. Below `MIN_CHARS` the remainder can't
  5275. // hold one readable block, so it stays a drop rather than a stutter of
  5276. // fragments the next call's dedup then has to shred around.
  5277. const room = cap - projectedChars - GAP_MARKER.length;
  5278. if (room < EXPLORE_ALLOCATION.MIN_CHARS) continue;
  5279. const section = renderCluster(rc.c, room, room);
  5280. const text = sectionText(section.parts);
  5281. if (text.length === 0) continue;
  5282. // The never-empty floors inside the windowing may overrun `room` (a
  5283. // 12-line minimum window on a file of very long lines). The first cluster
  5284. // is allowed that overshoot — an empty section is worse — but a later one
  5285. // is not: it would be spending a lower-ranked FILE's reservation for a
  5286. // fragment. Drop it, exactly as before.
  5287. if (projectedChars + text.length + GAP_MARKER.length > cap) continue;
  5288. renderedClusters.set(rc.idx, section);
  5289. anyClusterShrunk = anyClusterShrunk || section.shrunk;
  5290. chosenIndices.add(rc.idx);
  5291. projectedChars += text.length + GAP_MARKER.length;
  5292. }
  5293. // Emit chosen clusters in source order so the file reads top-to-bottom.
  5294. // Assembled through a function because it may have to run more than once:
  5295. // the fit test below trims the weakest cluster and re-assembles rather
  5296. // than skipping the file (CG-26).
  5297. const assembleSection = (chosen: ReadonlySet<number>) => {
  5298. let text = '';
  5299. const symbols: string[] = [];
  5300. const ranges: ExploreLineRange[] = [];
  5301. const covered: ExploreLineRange[] = [];
  5302. for (let i = 0; i < clusters.length; i++) {
  5303. if (!chosen.has(i)) continue;
  5304. const cluster = clusters[i]!;
  5305. const section = renderedClusters.get(i)!;
  5306. const part = sectionText(section.parts);
  5307. if (part.length > 0) {
  5308. if (text.length > 0) text += GAP_MARKER;
  5309. text += part;
  5310. }
  5311. ranges.push(...section.parts.map((p) => p.range));
  5312. covered.push(...section.covered);
  5313. symbols.push(...cluster.symbols);
  5314. }
  5315. return { text, symbols, ranges, covered };
  5316. };
  5317. let assembled = assembleSection(chosenIndices);
  5318. // A chosen cluster is a COMPLETE method-range — we never cut through a body,
  5319. // and a shrunk cluster drops WHOLE members for the same reason. An oversize
  5320. // single MEMBER (one long monolithic function) is kept whole for as long as
  5321. // it fits the bounded overshoot (half a method is useless — the agent just
  5322. // Reads the rest, the fallback explore exists to prevent); past that bound it
  5323. // is WINDOWED on whole lines rather than dropped (CG-30), so a god-method
  5324. // can neither be silently lost nor spend the response's whole envelope.
  5325. if (chosenIndices.size < clusters.length || anyClusterShrunk) {
  5326. anyFileTrimmed = true;
  5327. }
  5328. // Dedupe + cap the symbols list shown in the per-file header. Some
  5329. // files (Session.swift in Alamofire) produced 3.4KB symbol lists
  5330. // from cluster scoring + edge-source lines, dwarfing the per-file
  5331. // body cap. Show top names by frequency, with a "+N more" tail.
  5332. const headerFor = (symbols: readonly string[]): string => {
  5333. const symbolCounts = new Map<string, number>();
  5334. for (const s of symbols) symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
  5335. const sortedSymbols = [...symbolCounts.entries()]
  5336. .sort((a, b) => b[1] - a[1])
  5337. .map(([name]) => name);
  5338. const headerSymbols = sortedSymbols.slice(0, budget.maxSymbolsInFileHeader);
  5339. const omittedCount = sortedSymbols.length - headerSymbols.length;
  5340. return fileSectionHeader(filePath, omittedCount > 0
  5341. ? `${headerSymbols.join(', ')}, +${omittedCount} more`
  5342. : headerSymbols.join(', '));
  5343. };
  5344. // Last stop before the hard ceiling. The reservation already bounded cluster
  5345. // selection above, so reaching this means the bounded overshoot (an oversize
  5346. // first cluster, taken whole rather than sliced mid-method) ran the response
  5347. // out of room.
  5348. //
  5349. // Exact, like the whole-file arm above (CG-26): header + fences + body,
  5350. // not body + a flat 200. The displacement half of the invariant is
  5351. // already enforced on the body itself (`bodyCap` / `SPINE_CEILING` read
  5352. // `fundedHeadroom`); this is the ceiling half. And because it is exact it
  5353. // now bites at the margin — a header runs 300–500 chars where the body
  5354. // budget assumed 200 — so an overrun TRIMS the weakest cluster and
  5355. // re-assembles instead of skipping the file whole. Skipping a file over a
  5356. // ~300-char accounting difference is starvation by rounding: the file was
  5357. // admitted, reserved and rendered, and would have delivered nothing.
  5358. // Only when the top-ranked cluster alone cannot fit is the file skipped —
  5359. // that one is never sliced mid-method.
  5360. let fileHeader = headerFor(assembled.symbols);
  5361. let chosenNow = chosenIndices;
  5362. const costOfSection = (header: string, body: string) =>
  5363. header.length + 2 + (body.length > 0 ? body.length + lang.length + 11 : 0);
  5364. // The weakest cluster is SHRUNK into the room that is left before it is
  5365. // dropped (CG-36). Dropping it whole makes this loop as all-or-nothing as
  5366. // the selection above it was, and at the same cost: on excalidraw's
  5367. // `typeChecks.ts` the estimate missed by 13 chars and a 1,512-char cluster
  5368. // — the file's highest-SCORING one, last only because rank breaks ties on
  5369. // density — was thrown away to pay for it. Below MIN_CHARS the remainder
  5370. // cannot hold a readable block, and only then is the cluster dropped.
  5371. const reshrunkOnce = new Set<number>();
  5372. while (totalChars + costOfSection(fileHeader, assembled.text) > renderCeiling
  5373. && chosenNow.size > 1) {
  5374. // Weakest first: `rankedClusters` is best-first, so walk it backwards.
  5375. let weakest = -1;
  5376. for (let i = rankedClusters.length - 1; i >= 0; i--) {
  5377. const idx = rankedClusters[i]!.idx;
  5378. if (chosenNow.has(idx)) { weakest = idx; break; }
  5379. }
  5380. if (weakest < 0) break;
  5381. const over = totalChars + costOfSection(fileHeader, assembled.text) - renderCeiling;
  5382. const current = renderedClusters.get(weakest)!;
  5383. const currentLen = sectionText(current.parts).length;
  5384. const room = currentLen - over;
  5385. let reduced = false;
  5386. // One attempt per cluster: a second pass means the first re-render did
  5387. // not buy enough (the header moved with it), and the cluster is then
  5388. // dropped rather than whittled a few chars at a time.
  5389. if (room >= EXPLORE_ALLOCATION.MIN_CHARS && !reshrunkOnce.has(weakest)) {
  5390. reshrunkOnce.add(weakest);
  5391. const reshrunk = renderCluster(clusters[weakest]!, room, room);
  5392. const reshrunkLen = sectionText(reshrunk.parts).length;
  5393. // Strictly smaller, or this loop cannot make progress and would spin.
  5394. if (reshrunkLen > 0 && reshrunkLen < currentLen) {
  5395. renderedClusters.set(weakest, reshrunk);
  5396. anyClusterShrunk = true;
  5397. reduced = true;
  5398. }
  5399. }
  5400. if (!reduced) {
  5401. const trimmed = new Set(chosenNow);
  5402. trimmed.delete(weakest);
  5403. chosenNow = trimmed;
  5404. }
  5405. assembled = assembleSection(chosenNow);
  5406. fileHeader = headerFor(assembled.symbols);
  5407. anyFileTrimmed = true;
  5408. }
  5409. // One cluster left and still over — by the header estimate's error, at
  5410. // most a few hundred chars. Re-render it INTO the room that is actually
  5411. // left rather than skip the file: the same whole-line windowing an
  5412. // oversize cluster already gets (CG-30), just against an exact bound.
  5413. // The header is built from the cluster's symbols, not its text, so
  5414. // re-rendering cannot move the target.
  5415. if (totalChars + costOfSection(fileHeader, assembled.text) > renderCeiling
  5416. && chosenNow.size === 1) {
  5417. const idx = [...chosenNow][0]!;
  5418. const room = renderCeiling - totalChars
  5419. - (fileHeader.length + 2 + lang.length + 11);
  5420. if (room > 0) {
  5421. const reshrunk = renderCluster(clusters[idx]!, room, room);
  5422. renderedClusters.set(idx, reshrunk);
  5423. anyClusterShrunk = anyClusterShrunk || reshrunk.shrunk;
  5424. assembled = assembleSection(chosenNow);
  5425. anyFileTrimmed = true;
  5426. }
  5427. }
  5428. if (totalChars + costOfSection(fileHeader, assembled.text) > renderCeiling) {
  5429. anyFileTrimmed = true;
  5430. diag?.recordSkip(filePath, 'budget-clusters');
  5431. continue;
  5432. }
  5433. const fileSection = assembled.text;
  5434. const sectionRanges = assembled.ranges;
  5435. const coveredRanges = assembled.covered;
  5436. // The undeduped render of the same clusters, needed only if this file ends
  5437. // up fully back-referenced AND the whole call finds nothing new to say —
  5438. // see `suppressedFallback`. Built lazily: on every other call it is dead
  5439. // weight.
  5440. const fullClusterParts = fileSection.length === 0
  5441. ? clusters.flatMap((c, i) => (chosenNow.has(i) ? buildSection(c) : []))
  5442. : [];
  5443. emitFileSection({
  5444. header: fileHeader,
  5445. body: fileSection,
  5446. ranges: sectionRanges,
  5447. covered: mergeRanges(coveredRanges),
  5448. overhead: 200,
  5449. mode: 'clusters',
  5450. // Windowing an oversize member elides source too — reporting it as
  5451. // unclipped would hide exactly the cut the diagnostic exists to show.
  5452. clipped: chosenNow.size < clusters.length || anyClusterShrunk,
  5453. fullBody: sectionText(fullClusterParts),
  5454. fullRanges: fullClusterParts.map((p) => p.range),
  5455. });
  5456. }
  5457. // Anti-abandonment restore (CG-18). Dedup withheld everything and nothing new
  5458. // took its place — the response would be pointers only, which is the shape
  5459. // that reads as "codegraph found nothing" and sends the agent to Read for
  5460. // good. Put the top suppressed file back, in full, and keep its pointer off.
  5461. // Deliberately checked against `newSourceChars` (source THIS call emitted)
  5462. // rather than the response length: the flow and blast-radius sections are
  5463. // always there, and they are not what makes a response feel sufficient.
  5464. // Cast, not annotation: the only writer is the render loop's `emitFileSection`
  5465. // closure, which TypeScript's flow analysis cannot see, so it narrows the
  5466. // variable to `null` here and the truthiness check below would be `never`.
  5467. const restore = suppressedFallback as SuppressedFallback | null;
  5468. if (newSourceChars === 0 && restore) {
  5469. if (totalChars + restore.sourceChars + restore.overhead <= renderCeiling) {
  5470. lines.splice(restore.at, restore.replacing, ...restore.section);
  5471. totalChars += restore.sourceChars + restore.overhead;
  5472. sourceSpent += restore.sourceChars;
  5473. newSourceChars += restore.sourceChars;
  5474. filesIncluded++;
  5475. const idx = backReferencedFiles.indexOf(restore.filePath);
  5476. if (idx >= 0) backReferencedFiles.splice(idx, 1);
  5477. emittedByFile.set(restore.filePath, {
  5478. ranges: [...restore.ranges],
  5479. bytes: restore.sourceChars,
  5480. fingerprint: restore.fingerprint,
  5481. });
  5482. diag?.recordRender(restore.filePath, 'clusters', restore.sourceChars, false);
  5483. diag?.recordDedup(restore.filePath, 0, []);
  5484. }
  5485. }
  5486. // The back-reference convention, stated once where the verbatim guarantee is
  5487. // (#1474 does the same for drift). Without it a pointer reads as an
  5488. // apology for missing source rather than as an index into source the agent
  5489. // already has.
  5490. if (backReferencedFiles.length > 0) {
  5491. lines[verbatimHeaderIdx] += ` (Files marked **"Already sent earlier in this conversation"** are not repeated: their source came back on an earlier codegraph_explore call in THIS conversation and the file has not changed since, so that copy is exact and current — scroll back for it rather than re-fetching or Reading.)`;
  5492. }
  5493. // Drift epilogue (#1474). The "verbatim / do not Read" guarantee above
  5494. // stays TRUE for everything actually rendered (drifted files ship whole or
  5495. // not at all — never as a possibly-wrong slice), but two caveats must be
  5496. // explicit: omitted files need Reading, and index-derived LINE REFERENCES
  5497. // to any drifted file (flow steps, blast radius, trail) may be shifted.
  5498. if (staleOmitted.length > 0) {
  5499. lines[verbatimHeaderIdx] += ' (Exception: files flagged "⚠ changed on disk" below drifted from the index after their last sync — their source is omitted rather than risk a mis-sliced block; Read those specific files.)';
  5500. }
  5501. const staleAll = [...new Set([...staleOmitted, ...staleRendered])];
  5502. if (staleAll.length > 0) {
  5503. lines.push(
  5504. '',
  5505. `> ⚠ Changed on disk after the last index sync: ${staleAll.join(', ')}. Line numbers referencing ${staleAll.length === 1 ? 'this file' : 'these files'} elsewhere in this response (flow steps, blast radius, symbol lists) may be shifted until that project's next sync re-indexes ${staleAll.length === 1 ? 'it' : 'them'}.`,
  5506. );
  5507. }
  5508. // Everything pushed from here on is EPILOGUE — meta-text ABOUT the response
  5509. // rather than part of it. Marked so the hard-ceiling cut at the end can
  5510. // spend it before it spends a rendered file section (CG-31): a section is
  5511. // source the agent otherwise has to Read; the epilogue is a pointer list and
  5512. // two reminders, and the note that replaces it carries their instruction.
  5513. //
  5514. // Drawn AFTER the drift warning on purpose — that one is an honesty claim
  5515. // about source we did render, not a note about the response, so it is never
  5516. // the thing we drop. Lines already in `lines` are only MUTATED from here on
  5517. // (the verbatim header, the summary sentinel), never re-ordered, so the
  5518. // index stays valid.
  5519. const epilogueStart = lines.length;
  5520. // The curated header count is computed from the files that SURVIVE the final
  5521. // truncation (see end of method) — `filesIncluded` can over-count when the
  5522. // hard ceiling drops trailing sections — so leave a sentinel here and fill it
  5523. // in once the output is final.
  5524. lines[summaryLineIdx] = SUMMARY_SENTINEL;
  5525. // Add remaining files as references (from both relevant and peripheral files).
  5526. // Small projects (per budget) skip this — the relevant story already fits
  5527. // in the source section, and a trailing pointer list is pure overhead. But a
  5528. // CLIFFED file is source we deliberately withheld, so the list is forced on
  5529. // whenever there is one: withholding a file's bytes is only cheap if the agent
  5530. // can still name it in a follow-up call (CG-12).
  5531. // The epilogue's three blocks are BUILT here and FITTED below (CG-26) —
  5532. // they are not pushed straight into `lines` any more. The render loop
  5533. // budgets for the epilogue floor it committed to (`EPILOGUE_FLOOR`); what
  5534. // the response can afford above that floor is only known now, so the
  5535. // blocks are assembled against the room that actually remains, in priority
  5536. // order, instead of being emitted whole and then discarded whole.
  5537. const pointerEntries: string[] = [];
  5538. let pointerOmitted = 0;
  5539. if (budget.includeAdditionalFiles || cliffedFiles.size > 0) {
  5540. // Everything ranked that didn't render, in rank order — cliffed files first,
  5541. // since they outrank whatever the file cap cut. (Indexing by `filesIncluded`
  5542. // would be wrong now that cliffed files are skipped without consuming a slot.)
  5543. const rendered = new Set(renderedFilePaths);
  5544. const remainingRelevant = sortedFiles.filter(([fp]) => !rendered.has(fp));
  5545. // Ranked files are already covered by `remainingRelevant`; the rest of the
  5546. // gather (below the floor) becomes the pointer list. The Set guards the
  5547. // one overlap case — a file the SCORE_FLOOR_KEEP_MIN fallback pulled in
  5548. // despite scoring under the relative floor.
  5549. const rankedPaths = new Set(sortedFiles.map(([fp]) => fp));
  5550. const peripheralFiles = [...fileGroups.entries()]
  5551. .filter(([fp, group]) => group.score < scoreFloor && !rankedPaths.has(fp))
  5552. .sort((a, b) => b[1].score - a[1].score);
  5553. const remainingFiles = [...remainingRelevant, ...peripheralFiles];
  5554. for (const [filePath, group] of remainingFiles.slice(0, POINTER_MAX_FILES)) {
  5555. pointerEntries.push(pointerLineFor(filePath, group.nodes));
  5556. }
  5557. pointerOmitted = Math.max(0, remainingFiles.length - pointerEntries.length);
  5558. }
  5559. // Completeness signal so agents know they don't need to re-read these files.
  5560. // On small projects the budget gates this off — but if we actually had to
  5561. // trim or drop clusters, surface a brief note so the agent knows it can
  5562. // still Read for more detail.
  5563. const completenessBlock: string[] = budget.includeCompletenessSignal
  5564. ? ['', '---', `> **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.`]
  5565. : anyFileTrimmed
  5566. ? ['', `> 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.`]
  5567. : [];
  5568. // Explore budget note based on project size.
  5569. let budgetBlock: string[] = [];
  5570. if (budget.includeBudgetNote) {
  5571. try {
  5572. const stats = cg.getStats();
  5573. const callBudget = getExploreBudget(stats.fileCount);
  5574. budgetBlock = ['', `> **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}.`];
  5575. } catch {
  5576. // Stats unavailable — skip budget note
  5577. }
  5578. }
  5579. // FIT THE EPILOGUE (CG-26). Before this, the epilogue was emitted whole and
  5580. // then, on a saturated response, discarded whole by the hard ceiling — four
  5581. // of six suite repos shipped with no pointer list and no reminders at all,
  5582. // and the render loop had "budgeted" 600 chars for something that measures
  5583. // 1,064–2,231. Neither number was the real one, because the epilogue is not
  5584. // one thing: a fixed floor the loop reserves for (the cut note, plus a
  5585. // pointer for every file whose bytes were deliberately WITHHELD — CG-12
  5586. // makes those names load-bearing) and an elastic tail that takes what is
  5587. // left. Assembled in priority order — the do-not-re-read reminder first,
  5588. // then pointers in rank order, then the budget note — and emitted in
  5589. // document order.
  5590. const roomFor = (block: readonly string[]): number =>
  5591. block.reduce((n, s) => n + s.length + 1, 0);
  5592. let room = hardCeiling - (flow.text.length + lines.join('\n').length);
  5593. const keepCompleteness = completenessBlock.length > 0
  5594. && roomFor(completenessBlock) <= room;
  5595. if (keepCompleteness) room -= roomFor(completenessBlock);
  5596. const pointerBlock: string[] = [];
  5597. if (pointerEntries.length > 0) {
  5598. const head = [POINTER_HEADER, ''];
  5599. let left = room - roomFor(head);
  5600. if (left >= 0) {
  5601. let taken = 0;
  5602. for (const entry of pointerEntries) {
  5603. // Every entry we do NOT take has to be confessed by the tail line, so
  5604. // the tail's cost is part of taking one less than all of them.
  5605. const dropped = pointerEntries.length - taken - 1 + pointerOmitted;
  5606. const tail = dropped > 0 ? roomFor([`- ... and ${dropped} more files`]) : 0;
  5607. if (entry.length + 1 + tail > left) break;
  5608. left -= entry.length + 1;
  5609. taken++;
  5610. }
  5611. if (taken > 0) {
  5612. pointerBlock.push(...head, ...pointerEntries.slice(0, taken));
  5613. const dropped = pointerEntries.length - taken + pointerOmitted;
  5614. if (dropped > 0) pointerBlock.push(`- ... and ${dropped} more files`);
  5615. room -= roomFor(pointerBlock);
  5616. }
  5617. }
  5618. }
  5619. // Nothing of the pointer list survived, but there WAS one — say so, in the
  5620. // one line that carries its instruction forward.
  5621. const pointersLost = pointerEntries.length > 0 && pointerBlock.length === 0;
  5622. const keepBudgetNote = budgetBlock.length > 0 && roomFor(budgetBlock) <= room;
  5623. if (keepBudgetNote) room -= roomFor(budgetBlock);
  5624. lines.push(...pointerBlock);
  5625. if (keepCompleteness) lines.push(...completenessBlock);
  5626. if (keepBudgetNote) lines.push(...budgetBlock);
  5627. if (pointersLost && roomFor([EPILOGUE_LOST_NOTE, '']) <= room) {
  5628. lines.push('', EPILOGUE_LOST_NOTE);
  5629. }
  5630. const output = flow.text + lines.join('\n');
  5631. let finalText: string;
  5632. // The epilogue costs less than a file section, so it is cut FIRST (CG-31).
  5633. // Dropping a trailing section throws away source the render loop had already
  5634. // set that file's reservation aside for — the exact starvation the
  5635. // displacement guard exists to prevent, arriving after the guard has done
  5636. // its work. The epilogue is a pointer list and two reminders; its own
  5637. // "explore these names" instruction survives in the note below.
  5638. const epilogueOnlyCut = epilogueStart < lines.length
  5639. ? flow.text + lines.slice(0, epilogueStart).join('\n')
  5640. : null;
  5641. const EPILOGUE_CUT_NOTE = '\n\n> (Trailing notes omitted for size. The source above is complete and verbatim — treat it as already Read. For anything this call did not cover, run another codegraph_explore with the specific names rather than reading those files.)';
  5642. if (output.length > hardCeiling
  5643. && epilogueOnlyCut !== null
  5644. && epilogueOnlyCut.length + EPILOGUE_CUT_NOTE.length <= hardCeiling) {
  5645. finalText = epilogueOnlyCut + EPILOGUE_CUT_NOTE;
  5646. } else if (output.length > hardCeiling) {
  5647. // Still over with the epilogue gone: cut at a FILE-SECTION boundary (the
  5648. // last ``**` `` file header before the ceiling) so we drop whole trailing
  5649. // file-sections rather than slicing
  5650. // through a method body — a half-rendered method just forces the Read this
  5651. // tool exists to prevent. Fall back to a line boundary only if no section
  5652. // header sits in the back half (degenerate single-giant-section case).
  5653. const cut = output.slice(0, hardCeiling);
  5654. const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
  5655. const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
  5656. const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
  5657. finalText = 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.)';
  5658. } else {
  5659. finalText = output;
  5660. }
  5661. // Curated header (#1046): substitute the sentinel with the count of files
  5662. // whose source SURVIVES in the final text — not `subgraph`/`fileGroups` (the
  5663. // raw gather a broad query inflates) and not `filesIncluded` (which can
  5664. // over-count when the ceiling above drops trailing sections). A file counts
  5665. // only if its section header is still present; its relevant (non-import)
  5666. // symbols are summed for N. Files we couldn't fit are still named under "Not
  5667. // shown above" + the budget note, so nothing is silently dropped.
  5668. const survivors = renderedFilePaths.filter((fp) =>
  5669. finalText.includes(`${FILE_SECTION_PREFIX}${fp}\``));
  5670. const shownSymbols = survivors.reduce((sum, fp) => {
  5671. const g = fileGroups.get(fp);
  5672. if (!g) return sum;
  5673. return sum + new Set(
  5674. g.nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export').map((n) => n.id),
  5675. ).size;
  5676. }, 0);
  5677. let summaryLine = survivors.length > 0
  5678. ? `Found ${shownSymbols} symbol${shownSymbols === 1 ? '' : 's'} across ${survivors.length} file${survivors.length === 1 ? '' : 's'}.`
  5679. : `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
  5680. // Path pinning is visible, not silent: say which query-named files were
  5681. // honored, and which path spans matched nothing so the agent can correct
  5682. // them instead of trusting a response that quietly ignored the path.
  5683. const pinnedShown = pinnedFiles.filter((fp) => survivors.includes(fp)).length;
  5684. if (pinnedShown > 0) {
  5685. summaryLine += ` ${pinnedShown} file${pinnedShown === 1 ? '' : 's'} pinned from the query.`;
  5686. }
  5687. if (unresolvedPathSpans.length > 0) {
  5688. summaryLine += ` No indexed file uniquely matches ${unresolvedPathSpans.map((s) => `\`${s}\``).join(', ')}.`;
  5689. }
  5690. finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
  5691. // Emit the allocation diagnostic from the FINAL text, so per-file bytes and
  5692. // shares account for the hard-ceiling truncation above (CG-4).
  5693. diag?.finish(finalText, output.length, hardCeiling, filesIncluded);
  5694. // Session record (CG-17): only the files that SURVIVED the hard ceiling —
  5695. // a section the truncation dropped was never delivered, and recording it
  5696. // would let a later call withhold source the agent has never seen.
  5697. //
  5698. // A back-referenced file records its spans at ZERO bytes (CG-18) — it is
  5699. // still source the agent holds for this file, which is what the record
  5700. // means; dropping it would let the span age out of the retained window and
  5701. // be re-served for nothing.
  5702. const emittedFiles: ExploreFileEmission[] = [];
  5703. let sourceBytes = 0;
  5704. for (const fp of survivors) {
  5705. const emitted = emittedByFile.get(fp);
  5706. if (!emitted || emitted.ranges.length === 0) continue;
  5707. emittedFiles.push({
  5708. path: fp,
  5709. ranges: emitted.ranges,
  5710. bytes: emitted.bytes,
  5711. fingerprint: emitted.fingerprint,
  5712. });
  5713. sourceBytes += emitted.bytes;
  5714. }
  5715. return this.exploreResult(finalText, {
  5716. projectRoot,
  5717. query,
  5718. files: emittedFiles,
  5719. sourceBytes,
  5720. responseBytes: finalText.length,
  5721. });
  5722. }
  5723. /**
  5724. * An explore response plus the record of what it emitted (CG-17). The record
  5725. * rides the result only as far as {@link execute}, which files it into the
  5726. * calling session's state and deletes it — see {@link EXPLORE_EMISSION_KEY}.
  5727. */
  5728. private exploreResult(text: string, emission: ExploreEmission): ToolResult {
  5729. const result = this.textResult(text);
  5730. result[EXPLORE_EMISSION_KEY] = emission;
  5731. return result;
  5732. }
  5733. /**
  5734. * Handle codegraph_node
  5735. */
  5736. private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
  5737. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  5738. // Default to false to minimize context usage
  5739. const includeCode = args.includeCode === true;
  5740. const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
  5741. const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
  5742. const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : undefined;
  5743. const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : undefined;
  5744. const symbolsOnly = args.symbolsOnly === true;
  5745. const symbolRaw = typeof args.symbol === 'string' ? args.symbol.trim() : '';
  5746. // FILE READ MODE: a `file` with no `symbol` reads that file like the Read
  5747. // tool — its current on-disk source with line numbers, narrowable with
  5748. // `offset`/`limit` exactly as Read does — PLUS a one-line blast-radius
  5749. // header (which files depend on it). `symbolsOnly` returns just the
  5750. // structural map instead. Backed by the index: same bytes Read gives you.
  5751. if (!symbolRaw && fileHint) {
  5752. return this.handleFileView(cg, fileHint, { offset, limit, symbolsOnly });
  5753. }
  5754. const symbol = this.validateString(args.symbol, 'symbol');
  5755. if (typeof symbol !== 'string') return symbol;
  5756. let matches = this.findSymbolMatches(cg, symbol);
  5757. if (matches.length === 0) {
  5758. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  5759. }
  5760. // Disambiguate a heavily-overloaded name to a specific definition the caller
  5761. // pinned by file/line (the `file:line` a trail or another tool showed it) —
  5762. // so it can fetch e.g. `Harness::poll` at harness.rs:153 out of 50+ `poll`s
  5763. // instead of Reading. file matches by path suffix/substring; line prefers the
  5764. // def whose body contains it, else the nearest start. Only narrows (never
  5765. // empties — if a hint matches nothing it's ignored).
  5766. if (matches.length > 1 && (fileHint || lineHint !== undefined)) {
  5767. const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase();
  5768. let narrowed = matches;
  5769. if (fileHint) {
  5770. const fh = norm(fileHint);
  5771. const byFile = narrowed.filter((n) => norm(n.filePath).endsWith(fh) || norm(n.filePath).includes(fh));
  5772. if (byFile.length > 0) narrowed = byFile;
  5773. }
  5774. if (lineHint !== undefined && narrowed.length > 1) {
  5775. const containing = narrowed.filter((n) => n.startLine <= lineHint && (n.endLine ?? n.startLine) >= lineHint);
  5776. narrowed = containing.length > 0
  5777. ? containing
  5778. : [...narrowed].sort((a, b) => Math.abs(a.startLine - lineHint) - Math.abs(b.startLine - lineHint)).slice(0, 1);
  5779. }
  5780. if (narrowed.length > 0) matches = narrowed;
  5781. }
  5782. // Single definition — the common case.
  5783. if (matches.length === 1) {
  5784. return this.textResult(this.truncateOutput(await this.renderNodeSection(cg, matches[0]!, includeCode)));
  5785. }
  5786. // Multiple definitions share this name — overloads, or same-named methods on
  5787. // different types (Alamofire `didCompleteTask`/`task`/`validate`, gin
  5788. // `reset`). Returning ONE forces the agent to guess, and when it guesses
  5789. // wrong it READS the file to find the right overload — the dominant
  5790. // codegraph_node read cause on Swift/Go. So return them ALL: pack as many
  5791. // FULL bodies as fit a char budget (the agent gets the one it needs in this
  5792. // one call, no follow-up parameter to learn), and list any remainder by
  5793. // file:line so a large overload set can't overflow the per-tool cap.
  5794. const header = `**${matches.length} definitions named "${symbol}"**`;
  5795. if (!includeCode) {
  5796. const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
  5797. return this.textResult(this.truncateOutput(
  5798. [header, '', 'Re-query with `includeCode: true` to get every body in one call — no need to pick one first.', '', ...list].join('\n'),
  5799. ));
  5800. }
  5801. const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
  5802. // The CHAR budget is the real limiter — keep the count cap high so a set of
  5803. // SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
  5804. // render in full rather than relegating the one the agent wanted to a
  5805. // bodiless list. Only a set of many LARGE bodies hits the char budget first.
  5806. const HARD_CAP = 16;
  5807. const rendered: string[] = [];
  5808. const listed: Node[] = [];
  5809. let used = 0;
  5810. for (const n of matches) {
  5811. if (rendered.length >= HARD_CAP) { listed.push(n); continue; }
  5812. const section = await this.renderNodeSection(cg, n, true);
  5813. // Always emit the first; emit the rest only while within the char budget.
  5814. if (rendered.length === 0 || used + section.length <= BODY_BUDGET) {
  5815. rendered.push(section);
  5816. used += section.length;
  5817. } else {
  5818. listed.push(n);
  5819. }
  5820. }
  5821. const out: string[] = [
  5822. header,
  5823. `Returning ${rendered.length} in full${listed.length ? `; ${listed.length} more listed below` : ''} — pick the one you need (no Read required).`,
  5824. '',
  5825. rendered.join('\n\n---\n\n'),
  5826. ];
  5827. if (listed.length) {
  5828. const LIST_CAP = 20;
  5829. const shownList = listed.slice(0, LIST_CAP);
  5830. out.push(
  5831. '',
  5832. '**Other definitions**',
  5833. ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`),
  5834. );
  5835. if (listed.length > LIST_CAP) out.push(`- … +${listed.length - LIST_CAP} more`);
  5836. out.push(
  5837. '',
  5838. `> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0]!.filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`,
  5839. );
  5840. }
  5841. return this.textResult(this.truncateOutput(out.join('\n')));
  5842. }
  5843. /**
  5844. * FILE READ MODE: resolve `fileArg` (path or basename) to an indexed file and
  5845. * read it like the Read tool — its current on-disk source with line numbers,
  5846. * narrowable with `offset`/`limit` exactly as Read's are — preceded by a
  5847. * one-line blast-radius header (which files depend on it). `symbolsOnly`
  5848. * returns just the structural map (symbols + dependents) instead of source.
  5849. *
  5850. * Parity goal: the numbered source block is byte-for-byte the shape Read
  5851. * returns (`<n>\t<line>`, no padding), so the agent treats it as a Read — only
  5852. * faster (served from the index) and with the blast radius attached. Security:
  5853. * yaml/properties files are summarized by key, never dumped (#383); reads go
  5854. * through validatePathWithinRoot (#527).
  5855. */
  5856. private async handleFileView(
  5857. cg: CodeGraph,
  5858. fileArg: string,
  5859. opts: { offset?: number; limit?: number; symbolsOnly?: boolean } = {},
  5860. ): Promise<ToolResult> {
  5861. const normalize = (p: string) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, '');
  5862. const wantLower = normalize(fileArg).toLowerCase();
  5863. const allFiles = cg.getFiles();
  5864. if (allFiles.length === 0) return this.textResult('No files indexed. Run `codegraph index` first.');
  5865. let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower);
  5866. let candidates: typeof allFiles = [];
  5867. if (!resolved) {
  5868. candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
  5869. if (candidates.length === 1) resolved = candidates[0];
  5870. }
  5871. if (!resolved && candidates.length === 0) {
  5872. candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
  5873. if (candidates.length === 1) resolved = candidates[0];
  5874. }
  5875. if (!resolved && candidates.length > 1) {
  5876. return this.textResult(
  5877. [`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
  5878. ...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'),
  5879. );
  5880. }
  5881. if (!resolved) {
  5882. return this.textResult(
  5883. `No indexed file matches "${fileArg}". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly.`,
  5884. );
  5885. }
  5886. const filePath = resolved.path;
  5887. const nodes = cg.getNodesInFile(filePath)
  5888. .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
  5889. .sort((a, b) => a.startLine - b.startLine);
  5890. const dependents = cg.getFileDependents(filePath);
  5891. // Compact, one-line blast radius (codegraph's value-add over a plain Read).
  5892. const depSummary = dependents.length
  5893. ? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
  5894. : 'no other indexed file depends on it';
  5895. // Symbol-map renderer — for symbolsOnly, the config fallback, and read errors.
  5896. const symbolMap = (heading: string, limit = 200): string[] => {
  5897. const lines: string[] = [heading];
  5898. for (const n of nodes.slice(0, limit)) {
  5899. const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
  5900. lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
  5901. }
  5902. if (nodes.length > limit) lines.push(`- … +${nodes.length - limit} more`);
  5903. return lines;
  5904. };
  5905. // symbolsOnly → the cheap structural overview, no source.
  5906. if (opts.symbolsOnly) {
  5907. const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
  5908. if (nodes.length) out.push(...symbolMap('**Symbols**'));
  5909. else out.push('_No indexed symbols in this file._');
  5910. out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
  5911. return this.textResult(this.truncateOutput(out.join('\n')));
  5912. }
  5913. // SECURITY (#383): never dump a raw config/data file — a yaml/properties
  5914. // line is `key: <secret>`. Summarize by key and point to a real Read.
  5915. if (CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
  5916. const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
  5917. if (nodes.length) out.push(...symbolMap('**Keys (values withheld for safety)**'));
  5918. out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
  5919. return this.textResult(this.truncateOutput(out.join('\n')));
  5920. }
  5921. // Read the current bytes from disk through the security chokepoint
  5922. // (validatePathWithinRoot: blocks `../` traversal and symlink escapes, #527).
  5923. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  5924. let content: string | null = null;
  5925. if (abs) {
  5926. try { content = readFileSync(abs, 'utf-8'); } catch { content = null; }
  5927. }
  5928. if (content === null) {
  5929. const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
  5930. if (nodes.length) out.push(...symbolMap('**Symbols**'));
  5931. out.push('', `> Read \`${filePath}\` directly for its current content.`);
  5932. return this.textResult(this.truncateOutput(out.join('\n')));
  5933. }
  5934. // Split exactly as Read does — keep the trailing empty line a final newline
  5935. // produces (Read numbers it too), so line numbers line up byte-for-byte.
  5936. const fileLines = content.split('\n');
  5937. const total = fileLines.length;
  5938. // Read-parity windowing: `offset`/`limit` mean exactly what they do on Read
  5939. // (1-based start line; max line count). Default: the whole file, capped like
  5940. // Read at 2000 lines and bounded by a char budget that tracks explore's
  5941. // proven-safe ~38k response ceiling. Overflow is stated explicitly (Read
  5942. // paginates too) — never the silent 15k truncateOutput chop.
  5943. const CHAR_BUDGET = 38000;
  5944. const DEFAULT_LIMIT = 2000;
  5945. const offset = Math.max(1, opts.offset ?? 1);
  5946. if (offset > total) {
  5947. return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
  5948. }
  5949. const maxLines = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
  5950. const start = offset - 1; // 0-based
  5951. const header = `**${filePath}** — ${total} lines, ${nodes.length} symbol${nodes.length === 1 ? '' : 's'} · ${depSummary}`;
  5952. // Numbered lines, byte-for-byte Read's shape: `<n>\t<line>`, no left-pad.
  5953. const numbered: string[] = [];
  5954. let used = header.length + 8;
  5955. let i = start;
  5956. for (; i < total && numbered.length < maxLines; i++) {
  5957. const ln = `${i + 1}\t${fileLines[i]}`;
  5958. if (used + ln.length + 1 > CHAR_BUDGET && numbered.length > 0) break;
  5959. numbered.push(ln);
  5960. used += ln.length + 1;
  5961. }
  5962. const shownEnd = start + numbered.length;
  5963. const complete = offset === 1 && shownEnd >= total;
  5964. const out: string[] = [header, '', ...numbered];
  5965. if (!complete) {
  5966. out.push(
  5967. '',
  5968. `(lines ${offset}–${shownEnd} of ${total} — pass \`offset\`/\`limit\` for another range, or \`codegraph_node <symbol>\` for one symbol in full)`,
  5969. );
  5970. }
  5971. // Self-bounded to CHAR_BUDGET — do NOT route through truncateOutput (15k).
  5972. return this.textResult(out.join('\n'));
  5973. }
  5974. /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
  5975. private async renderNodeSection(cg: CodeGraph, node: Node, includeCode: boolean): Promise<string> {
  5976. // Disk-drift gate (issue #1474): the body below is CURRENT bytes sliced at
  5977. // INDEXED line ranges. If the file changed since its last index sync, that
  5978. // slice can be a DIFFERENT symbol's code served under this node's name —
  5979. // confidently wrong, with no watcher banner to catch it on a `projectPath`
  5980. // (cross-project) target. Never emit a slice from a drifted file.
  5981. if (this.isFileStaleOnDisk(cg, node.filePath)) {
  5982. return this.renderStaleNodeSection(cg, node, includeCode);
  5983. }
  5984. let code: string | null = null;
  5985. let outline: string | null = null;
  5986. if (includeCode) {
  5987. // For container symbols (class/interface/struct/…), the full body is the
  5988. // sum of every method body — a wall of source. Return a structural outline
  5989. // (members + signatures + line numbers) instead; leaf symbols return their
  5990. // full body.
  5991. if (CONTAINER_NODE_KINDS.has(node.kind)) {
  5992. outline = this.buildContainerOutline(cg, node);
  5993. }
  5994. if (!outline) {
  5995. code = await cg.getCode(node.id);
  5996. }
  5997. }
  5998. return this.formatNodeDetails(node, code, outline) + this.formatTrail(cg, node);
  5999. }
  6000. // Whole-file fallback caps for a drifted file (#1474): small enough to fit
  6001. // codegraph_node's output cap (MAX_OUTPUT_LENGTH) with headroom for the
  6002. // header + trail. A file within these bounds is served WHOLE and CURRENT
  6003. // (Read-parity, correct by construction) instead of a possibly-wrong slice.
  6004. private static readonly STALE_WHOLE_FILE_MAX_LINES = 300;
  6005. private static readonly STALE_WHOLE_FILE_MAX_CHARS = 12000;
  6006. /**
  6007. * codegraph_node render for a symbol whose file changed on disk after the
  6008. * last index sync (issue #1474). The indexed line range is no longer
  6009. * trustworthy, so no slice is emitted: a small file gets its full CURRENT
  6010. * source (Read-parity — sufficiency preserved, the agent still doesn't need
  6011. * Read); a large one gets an explicit notice steering to the tool's own
  6012. * file-read mode (or Read) — honest absence instead of confident wrongness.
  6013. * Location/signature stay (they're the index's answer) but are flagged as
  6014. * possibly shifted.
  6015. */
  6016. private renderStaleNodeSection(cg: CodeGraph, node: Node, includeCode: boolean): string {
  6017. const lines: string[] = [
  6018. `**${node.name}** (${node.kind})`,
  6019. '',
  6020. `**Location:** ${node.filePath}${node.startLine ? `:${node.startLine}` : ''} — ⚠ as of the last index sync; the file has changed on disk since, so this line may be shifted`,
  6021. ];
  6022. if (node.signature) {
  6023. lines.push(`**Signature:** \`${node.signature}\``);
  6024. }
  6025. lines.push('');
  6026. let embedded = false;
  6027. if (includeCode) {
  6028. try {
  6029. const absPath = validatePathWithinRoot(cg.getProjectRoot(), node.filePath);
  6030. if (absPath && existsSync(absPath) && !isConfigLeafNode(node)) {
  6031. const content = readFileSync(absPath, 'utf-8');
  6032. const body = content.replace(/\n+$/, '');
  6033. if (
  6034. body.length <= ToolHandler.STALE_WHOLE_FILE_MAX_CHARS &&
  6035. body.split('\n').length <= ToolHandler.STALE_WHOLE_FILE_MAX_LINES
  6036. ) {
  6037. lines.push(
  6038. `> ⚠ \`${node.filePath}\` changed on disk after it was last indexed, so the indexed line range for this symbol may no longer match. Showing the file's full CURRENT source instead (Read-parity — treat it as already Read):`,
  6039. '',
  6040. '```' + (node.language || ''),
  6041. numberSourceLines(body, 1),
  6042. '```',
  6043. );
  6044. embedded = true;
  6045. }
  6046. }
  6047. } catch {
  6048. /* fall through to the notice */
  6049. }
  6050. }
  6051. if (!embedded) {
  6052. lines.push(
  6053. `> ⚠ \`${node.filePath}\` changed on disk after it was last indexed — the indexed line range for this symbol no longer reliably matches, so its body is omitted rather than risk showing a different symbol's code. For current content, call codegraph_node with \`file: "${node.filePath}"\` (no symbol; \`offset\`/\`limit\` narrow it like Read), or Read the file. The change is picked up automatically on that project's next index sync.`,
  6054. );
  6055. }
  6056. return lines.join('\n') + this.formatTrail(cg, node);
  6057. }
  6058. /**
  6059. * Build the "trail" for a symbol: its direct callees (what it calls) and
  6060. * callers (what calls it), each with file:line — so codegraph_node doubles as
  6061. * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
  6062. * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
  6063. * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
  6064. * dynamic dispatch the static graph couldn't resolve — that absence is itself
  6065. * a signal (read that one hop) rather than a dead end.
  6066. */
  6067. private formatTrail(cg: CodeGraph, node: Node): string {
  6068. const TRAIL_CAP = 12;
  6069. const fmt = (e: { node: Node; edge: Edge }) => {
  6070. const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
  6071. const synth = this.synthEdgeNote(e.edge);
  6072. return synth ? `${base} [${synth.compact}]` : base;
  6073. };
  6074. const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
  6075. const seen = new Set<string>([node.id]);
  6076. const out: Array<{ node: Node; edge: Edge }> = [];
  6077. for (const e of edges) {
  6078. if (seen.has(e.node.id)) continue;
  6079. seen.add(e.node.id);
  6080. out.push(e);
  6081. }
  6082. return out;
  6083. };
  6084. const callees = collect(cg.getCallees(node.id));
  6085. const callers = collect(cg.getCallers(node.id));
  6086. if (callees.length === 0 && callers.length === 0) return '';
  6087. const lines: string[] = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**'];
  6088. if (callees.length > 0) {
  6089. lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
  6090. }
  6091. if (callers.length > 0) {
  6092. lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
  6093. }
  6094. return lines.join('\n');
  6095. }
  6096. /**
  6097. * Handle codegraph_status
  6098. */
  6099. private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
  6100. let cg = this.getCodeGraph(args.projectPath as string | undefined);
  6101. // Same trick as withStalenessNotice — when an explicit projectPath
  6102. // resolves to the same project as the default session cg, prefer the
  6103. // default so getPendingFiles() (only populated by the default's watcher)
  6104. // is non-empty when there are pending edits.
  6105. if (this.cg && cg !== this.cg) {
  6106. try {
  6107. if (resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot())) {
  6108. cg = this.cg;
  6109. }
  6110. } catch { /* closed instance — leave as is */ }
  6111. }
  6112. const stats = cg.getStats();
  6113. // Warn when this index actually belongs to a different git working tree
  6114. // (e.g. the server resolved up from a nested worktree to the main checkout).
  6115. // Queries then reflect that tree's branch, not the worktree being edited.
  6116. // status shows the verbose, multi-line form; the read tools get the compact
  6117. // one-liner via withWorktreeNotice. Both share the cached detection.
  6118. const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined);
  6119. const lines: string[] = [
  6120. '**CodeGraph Status**',
  6121. '',
  6122. ];
  6123. if (mismatch) {
  6124. lines.push(`> ⚠ ${worktreeMismatchWarning(mismatch).replace(/\n/g, '\n> ')}`, '');
  6125. }
  6126. lines.push(
  6127. `**Files indexed:** ${stats.fileCount}`,
  6128. `**Total nodes:** ${stats.nodeCount}`,
  6129. `**Total edges:** ${stats.edgeCount}`,
  6130. `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
  6131. );
  6132. // Surface the active SQLite backend (node:sqlite, Node's built-in real
  6133. // SQLite — full WAL + FTS5, no native build).
  6134. lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
  6135. // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
  6136. // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
  6137. // everywhere, so a non-wal mode means the filesystem can't (network/
  6138. // virtualized mounts, WSL2 /mnt). See issue #238.
  6139. const journalMode = cg.getJournalMode();
  6140. if (journalMode === 'wal') {
  6141. lines.push(`**Journal mode:** wal (concurrent reads safe)`);
  6142. } else {
  6143. lines.push(
  6144. `**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
  6145. `can block on a concurrent write (WAL appears unsupported on this filesystem)`
  6146. );
  6147. }
  6148. // A newer release exists (#1243) — status is where users and agents look
  6149. // when something seems off, so surface the drift here too. Cheap memoized
  6150. // cache read; absent entirely when up to date or opted out.
  6151. const updateNotice = getUpdateNotice();
  6152. if (updateNotice) {
  6153. lines.push(`**Update available:** ${updateNotice}`);
  6154. }
  6155. // Non-zero at rest means a resolution pass was interrupted mid-run, so
  6156. // some files' call/impact edges are missing until the next sync sweeps
  6157. // the leftovers (#1187). Surface it — an agent trusting an incomplete
  6158. // blast radius is worse than one that knows to re-sync.
  6159. const pendingRefs = cg.getPendingReferenceCount();
  6160. if (pendingRefs > 0) {
  6161. lines.push(
  6162. `**Pending resolution:** ⚠ ${pendingRefs} references from an interrupted ` +
  6163. `index run — some caller/impact edges are missing until the next sync ` +
  6164. `(any file change triggers it, or run \`codegraph sync\`)`
  6165. );
  6166. }
  6167. lines.push('', '**Nodes by Kind:**');
  6168. for (const [kind, count] of Object.entries(stats.nodesByKind)) {
  6169. if ((count as number) > 0) {
  6170. lines.push(`- ${kind}: ${count}`);
  6171. }
  6172. }
  6173. lines.push('', '**Languages:**');
  6174. for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
  6175. if ((count as number) > 0) {
  6176. lines.push(`- ${lang}: ${count}`);
  6177. }
  6178. }
  6179. // Whole-index degradation (#876): when live watching has permanently
  6180. // stopped, getPendingFiles() is empty (so no "Pending sync" section below)
  6181. // but the index is frozen — call that out explicitly here, the one place an
  6182. // agent asks "is the index caught up?".
  6183. if (cg.isWatcherDegraded()) {
  6184. lines.push(
  6185. '',
  6186. '**Auto-sync disabled:**',
  6187. `- ${cg.getWatcherDegradedReason() ?? 'live file watching stopped'}`,
  6188. '- The index is frozen; Read files directly for current content.'
  6189. );
  6190. }
  6191. // Per-file freshness — the inverse of the auto-prepended staleness banner
  6192. // (issue #403). Surfacing it inside `status` gives the agent a single
  6193. // place to ask "is the index caught up?" rather than inferring from
  6194. // banners on other tool calls.
  6195. const pending = cg.getPendingFiles();
  6196. if (pending.length > 0) {
  6197. lines.push('', '**Pending sync:**');
  6198. const now = Date.now();
  6199. for (const p of pending) {
  6200. const ageMs = Math.max(0, now - p.lastSeenMs);
  6201. const label = p.indexing ? 'indexing in progress' : 'pending sync';
  6202. lines.push(`- ${p.path} (edited ${ageMs}ms ago, ${label})`);
  6203. }
  6204. }
  6205. return this.textResult(lines.join('\n'));
  6206. }
  6207. /**
  6208. * Handle codegraph_files - get project file structure from the index
  6209. */
  6210. private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
  6211. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  6212. const pathFilter = args.path as string | undefined;
  6213. const pattern = args.pattern as string | undefined;
  6214. const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
  6215. const includeMetadata = args.includeMetadata !== false;
  6216. const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
  6217. // Get all files from the index
  6218. const allFiles = cg.getFiles();
  6219. if (allFiles.length === 0) {
  6220. return this.textResult('No files indexed. Run `codegraph index` first.');
  6221. }
  6222. // Filter by path prefix. Stored paths are project-relative POSIX (e.g.
  6223. // "src/foo.ts"), but agents commonly pass project-root variants like "/",
  6224. // ".", "./", "" or Windows-style "src\foo" — and prefixes with leading
  6225. // "/", "./" or "\". Normalize all of those before matching so the agent
  6226. // gets results instead of falling back to Read/Glob (see #426).
  6227. const normalizedFilter = pathFilter
  6228. ? pathFilter
  6229. .replace(/\\/g, '/')
  6230. .replace(/^(?:\.?\/+)+/, '')
  6231. .replace(/^\.$/, '')
  6232. .replace(/\/+$/, '')
  6233. : '';
  6234. let files = normalizedFilter
  6235. ? allFiles.filter(f => f.path === normalizedFilter || f.path.startsWith(normalizedFilter + '/'))
  6236. : allFiles;
  6237. // Filter by glob pattern
  6238. if (pattern) {
  6239. const regex = this.globToRegex(pattern);
  6240. files = files.filter(f => regex.test(f.path));
  6241. }
  6242. if (files.length === 0) {
  6243. return this.textResult(`No files found matching the criteria.`);
  6244. }
  6245. // Format output
  6246. let output: string;
  6247. switch (format) {
  6248. case 'flat':
  6249. output = this.formatFilesFlat(files, includeMetadata);
  6250. break;
  6251. case 'grouped':
  6252. output = this.formatFilesGrouped(files, includeMetadata);
  6253. break;
  6254. case 'tree':
  6255. default:
  6256. output = this.formatFilesTree(files, includeMetadata, maxDepth);
  6257. break;
  6258. }
  6259. return this.textResult(this.truncateOutput(output));
  6260. }
  6261. /**
  6262. * Convert glob pattern to regex
  6263. */
  6264. private globToRegex(pattern: string): RegExp {
  6265. const escaped = pattern
  6266. .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
  6267. .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
  6268. .replace(/\*/g, '[^/]*') // * matches anything except /
  6269. .replace(/\?/g, '[^/]') // ? matches single char except /
  6270. .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
  6271. return new RegExp(escaped);
  6272. }
  6273. /**
  6274. * Format files as a flat list
  6275. */
  6276. private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  6277. const lines: string[] = [`**Files (${files.length})**`, ''];
  6278. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  6279. if (includeMetadata) {
  6280. lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
  6281. } else {
  6282. lines.push(`- ${file.path}`);
  6283. }
  6284. }
  6285. return lines.join('\n');
  6286. }
  6287. /**
  6288. * Format files grouped by language
  6289. */
  6290. private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  6291. const byLang = new Map<string, typeof files>();
  6292. for (const file of files) {
  6293. const existing = byLang.get(file.language) || [];
  6294. existing.push(file);
  6295. byLang.set(file.language, existing);
  6296. }
  6297. const lines: string[] = [`**Files by Language (${files.length} total)**`, ''];
  6298. // Sort languages by file count (descending)
  6299. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  6300. for (const [lang, langFiles] of sortedLangs) {
  6301. lines.push(`**${lang} (${langFiles.length})**`);
  6302. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  6303. if (includeMetadata) {
  6304. lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
  6305. } else {
  6306. lines.push(`- ${file.path}`);
  6307. }
  6308. }
  6309. lines.push('');
  6310. }
  6311. return lines.join('\n');
  6312. }
  6313. /**
  6314. * Format files as a tree structure
  6315. */
  6316. private formatFilesTree(
  6317. files: { path: string; language: string; nodeCount: number }[],
  6318. includeMetadata: boolean,
  6319. maxDepth?: number
  6320. ): string {
  6321. // Build tree structure
  6322. interface TreeNode {
  6323. name: string;
  6324. children: Map<string, TreeNode>;
  6325. file?: { language: string; nodeCount: number };
  6326. }
  6327. const root: TreeNode = { name: '', children: new Map() };
  6328. for (const file of files) {
  6329. const parts = file.path.split('/');
  6330. let current = root;
  6331. for (let i = 0; i < parts.length; i++) {
  6332. const part = parts[i];
  6333. if (!part) continue;
  6334. if (!current.children.has(part)) {
  6335. current.children.set(part, { name: part, children: new Map() });
  6336. }
  6337. current = current.children.get(part)!;
  6338. // If this is the last part, it's a file
  6339. if (i === parts.length - 1) {
  6340. current.file = { language: file.language, nodeCount: file.nodeCount };
  6341. }
  6342. }
  6343. }
  6344. // Render tree
  6345. const lines: string[] = [`**Project Structure (${files.length} files)**`, ''];
  6346. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  6347. if (maxDepth !== undefined && depth > maxDepth) return;
  6348. const connector = isLast ? '└── ' : '├── ';
  6349. const childPrefix = isLast ? ' ' : '│ ';
  6350. if (node.name) {
  6351. let line = prefix + connector + node.name;
  6352. if (node.file && includeMetadata) {
  6353. line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
  6354. }
  6355. lines.push(line);
  6356. }
  6357. const children = [...node.children.values()];
  6358. // Sort: directories first, then files, both alphabetically
  6359. children.sort((a, b) => {
  6360. const aIsDir = a.children.size > 0 && !a.file;
  6361. const bIsDir = b.children.size > 0 && !b.file;
  6362. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  6363. return a.name.localeCompare(b.name);
  6364. });
  6365. for (let i = 0; i < children.length; i++) {
  6366. const child = children[i]!;
  6367. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  6368. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  6369. }
  6370. };
  6371. renderNode(root, '', true, 0);
  6372. return lines.join('\n');
  6373. }
  6374. // =========================================================================
  6375. // Symbol resolution helpers
  6376. // =========================================================================
  6377. /**
  6378. * Find a symbol by name, handling disambiguation when multiple matches exist.
  6379. * Returns the best match and a note about alternatives if any.
  6380. */
  6381. /**
  6382. * Check if a node matches a symbol query.
  6383. *
  6384. * Accepts simple names (`run`) and three flavors of qualifier:
  6385. * - dotted `Session.request` (TS/JS/Python)
  6386. * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
  6387. * - slash `configurator/stage_apply` (path-ish)
  6388. *
  6389. * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
  6390. * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
  6391. * the canonical `crate::module::symbol` form resolves.
  6392. *
  6393. * Resolution order, last part must always equal `node.name`:
  6394. * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
  6395. * where the extractor builds the qualified name from the AST stack)
  6396. * 2. File-path containment (handles file-derived modules in Rust/
  6397. * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
  6398. */
  6399. private matchesSymbol(node: Node, symbol: string): boolean {
  6400. // Simple name match
  6401. if (node.name === symbol) return true;
  6402. // File basename match (e.g., "product-card" matches "product-card.liquid")
  6403. if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
  6404. // Qualified-name lookups: split on any supported separator. `\w` keeps
  6405. // identifier chars (incl. `_`) intact; everything else is treated as
  6406. // a separator we tolerate.
  6407. if (!/[.\/]|::/.test(symbol)) return false;
  6408. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  6409. if (parts.length < 2) return false;
  6410. const lastPart = parts[parts.length - 1]!;
  6411. if (node.name !== lastPart) return false;
  6412. // Stage 1: qualified-name suffix match. The extractor joins the
  6413. // semantic hierarchy with `::`, so `Session.request` and
  6414. // `Session::request` both become `Session::request` here.
  6415. const colonSuffix = parts.join('::');
  6416. if (node.qualifiedName.includes(colonSuffix)) return true;
  6417. // Stage 2: file-path containment. Rust modules and Python packages
  6418. // are not in `qualifiedName` — they're encoded in the file path. So
  6419. // `stage_apply::run` matches a `run` in any file whose path
  6420. // contains a `stage_apply` segment (with or without an extension).
  6421. //
  6422. // Filter out Rust path prefixes that have no file-system equivalent.
  6423. const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
  6424. if (containerHints.length === 0) return false;
  6425. const segments = node.filePath.split('/').filter((s) => s.length > 0);
  6426. return containerHints.every((hint) =>
  6427. segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
  6428. );
  6429. }
  6430. /**
  6431. * Find ALL definitions matching a name, ranked, so codegraph_node can return
  6432. * every overload instead of guessing one (the wrong guess → a Read). Keepers
  6433. * rank before generated stubs (.pb.go etc.); stable within a group preserves
  6434. * FTS order. Returns [] when nothing matches; a qualified lookup that finds no
  6435. * exact match returns [] rather than a misleading fuzzy file hit (#173); a
  6436. * bare name with no exact match falls back to the single top fuzzy result.
  6437. */
  6438. private findSymbolMatches(cg: CodeGraph, symbol: string): Node[] {
  6439. const isQualified = /[.\/]|::/.test(symbol);
  6440. // For a bare name, enumerate EVERY exact-name definition via the direct index
  6441. // (not FTS, which caps + ranks): tokio's `poll` has 50+ defs and the one the
  6442. // caller wants (`Harness::poll` at harness.rs:153) ranks below any search cut,
  6443. // so it could be neither rendered nor pinned by the file/line disambiguator —
  6444. // and the agent Read it. With the full set, the multi-overload render + the
  6445. // file/line filter can both reach it.
  6446. if (!isQualified) {
  6447. const exact = cg.getNodesByName(symbol);
  6448. if (exact.length > 0) {
  6449. const isGen = cg.generatedFilePredicate(exact.map((n) => n.filePath));
  6450. return [...exact].sort((a, b) => (isGen(a.filePath) ? 1 : 0) - (isGen(b.filePath) ? 1 : 0));
  6451. }
  6452. // No exact match — use the single top fuzzy result (e.g. a file basename).
  6453. const fuzzy = cg.searchNodes(symbol, { limit: 10 });
  6454. return fuzzy[0] ? [fuzzy[0].node] : [];
  6455. }
  6456. // Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol.
  6457. const limit = 50;
  6458. let results = cg.searchNodes(symbol, { limit });
  6459. // FTS strips colons, so `stage_apply::run` searches the literal
  6460. // `stage_applyrun` and finds nothing. Re-search by the bare last part and
  6461. // let `matchesSymbol` filter by qualifier.
  6462. if (isQualified && results.length === 0) {
  6463. const tail = lastQualifierPart(symbol);
  6464. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
  6465. }
  6466. if (results.length === 0) return [];
  6467. const exactMatches = results.filter((r) => this.matchesSymbol(r.node, symbol));
  6468. if (exactMatches.length === 0) {
  6469. // No exact match — a qualified lookup must not fall back to a fuzzy file
  6470. // hit (#173); a bare name may use the single top fuzzy result.
  6471. return isQualified ? [] : results[0] ? [results[0].node] : [];
  6472. }
  6473. // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, and anything
  6474. // whose header declares it generated) so a flow query prefers the keeper
  6475. // implementation over the generated stub.
  6476. const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
  6477. return [...exactMatches]
  6478. .sort((a, b) => (isGen(a.node.filePath) ? 1 : 0) - (isGen(b.node.filePath) ? 1 : 0))
  6479. .map((r) => r.node);
  6480. }
  6481. /**
  6482. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  6483. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  6484. */
  6485. private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  6486. // Nix option paths: the declaration is stored as `options.<path>` and
  6487. // config writes carry longer/quoted tails (`<path>."git/config".text`),
  6488. // so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
  6489. // no exact-name node and would degrade to bare-tail FTS soup — burying
  6490. // the declaration hub the nix-option-path edges hang off. Resolve the
  6491. // convention directly: declaration first, then the exact write, then a
  6492. // capped prefix scan of write sites. Three index hits; non-nix graphs
  6493. // fall straight through.
  6494. if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
  6495. const optionHits = [
  6496. ...cg.getNodesByName(`options.${symbol}`),
  6497. ...cg.getNodesByName(symbol),
  6498. ...cg.getNodesByNamePrefix(`${symbol}.`, 12),
  6499. ].filter((n) => n.language === 'nix');
  6500. if (optionHits.length > 0) {
  6501. const seen = new Set<string>();
  6502. const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
  6503. return { nodes, note: '' };
  6504. }
  6505. }
  6506. let results = cg.searchNodes(symbol, { limit: 50 });
  6507. // Mirror the fallback in `findSymbol` for qualified queries — FTS
  6508. // strips colons, so a module-qualified lookup needs a second pass
  6509. // by the bare last part.
  6510. if (results.length === 0 && /[.\/]|::/.test(symbol)) {
  6511. const tail = lastQualifierPart(symbol);
  6512. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
  6513. }
  6514. if (results.length === 0) {
  6515. return { nodes: [], note: '' };
  6516. }
  6517. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  6518. if (exactMatches.length <= 1) {
  6519. const node = exactMatches[0]?.node ?? results[0]!.node;
  6520. return { nodes: [node], note: '' };
  6521. }
  6522. // Same generated-file down-rank as findSymbol — keeps callers/callees
  6523. // /impact aggregation aligned (a query against "Send" returns the
  6524. // hand-written implementations before the protobuf scaffold).
  6525. const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
  6526. const ranked = [...exactMatches].sort((a, b) => {
  6527. const aGen = isGen(a.node.filePath) ? 1 : 0;
  6528. const bGen = isGen(b.node.filePath) ? 1 : 0;
  6529. return aGen - bGen;
  6530. });
  6531. const locations = ranked.map(r =>
  6532. `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
  6533. );
  6534. const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
  6535. return { nodes: ranked.map(r => r.node), note };
  6536. }
  6537. /**
  6538. * Truncate output if it exceeds the maximum length
  6539. */
  6540. private truncateOutput(text: string): string {
  6541. if (text.length <= MAX_OUTPUT_LENGTH) return text;
  6542. const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
  6543. const lastNewline = truncated.lastIndexOf('\n');
  6544. const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
  6545. return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
  6546. }
  6547. // =========================================================================
  6548. // Formatting helpers (compact by default to reduce context usage)
  6549. // =========================================================================
  6550. private formatSearchResults(results: SearchResult[]): string {
  6551. const lines: string[] = [`**Search Results (${results.length} found)**`, ''];
  6552. for (const result of results) {
  6553. const { node } = result;
  6554. const location = node.startLine ? `:${node.startLine}` : '';
  6555. // Compact format: one line per result with key info
  6556. lines.push(`**${node.name}** (${node.kind})`);
  6557. lines.push(`${node.filePath}${location}`);
  6558. if (node.signature) lines.push(`\`${node.signature}\``);
  6559. lines.push('');
  6560. }
  6561. return lines.join('\n');
  6562. }
  6563. private formatNodeList(nodes: Node[], title: string, labels?: Map<string, string>): string {
  6564. const lines: string[] = [`**${title} (${nodes.length} found)**`, ''];
  6565. for (const node of nodes) {
  6566. const location = node.startLine ? `:${node.startLine}` : '';
  6567. // Compact: just name, kind, location — plus the relationship when it
  6568. // isn't a plain call (callback registration, instantiation, …).
  6569. const label = labels?.get(node.id);
  6570. lines.push(
  6571. `- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`
  6572. );
  6573. }
  6574. return lines.join('\n');
  6575. }
  6576. /**
  6577. * Relationship label for a non-`calls` edge in callers/callees lists. A
  6578. * function-as-value edge (#756) is the high-signal one: `callers(cb)`
  6579. * showing "via callback registration" tells the agent this is where the
  6580. * callback is WIRED, not where it's invoked.
  6581. */
  6582. private edgeLabel(edge: Edge): string | null {
  6583. if (edge.kind === 'calls') return null;
  6584. if (edge.metadata?.fnRef === true) return 'callback registration';
  6585. if (edge.kind === 'instantiates') return 'instantiation';
  6586. if (edge.kind === 'imports') return 'import';
  6587. if (edge.kind === 'references') return 'reference';
  6588. return edge.kind;
  6589. }
  6590. private formatImpact(symbol: string, impact: Subgraph): string {
  6591. const nodeCount = impact.nodes.size;
  6592. // Compact format: just list affected symbols grouped by file
  6593. const lines: string[] = [
  6594. `**Impact: "${symbol}" affects ${nodeCount} symbols**`,
  6595. '',
  6596. ];
  6597. // Group by file
  6598. const byFile = new Map<string, Node[]>();
  6599. for (const node of impact.nodes.values()) {
  6600. const existing = byFile.get(node.filePath) || [];
  6601. existing.push(node);
  6602. byFile.set(node.filePath, existing);
  6603. }
  6604. for (const [file, nodes] of byFile) {
  6605. lines.push(`**${file}:**`);
  6606. // Compact: inline list
  6607. const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  6608. lines.push(nodeList);
  6609. lines.push('');
  6610. }
  6611. return lines.join('\n');
  6612. }
  6613. /**
  6614. * Build a compact structural outline of a container symbol from its
  6615. * indexed children (methods, fields, properties, …) — name, kind,
  6616. * line number, and signature — so the agent gets the shape of a class
  6617. * without the full source of every method. Returns '' when the container
  6618. * has no indexed children, so the caller can fall back to full source.
  6619. */
  6620. private buildContainerOutline(cg: CodeGraph, node: Node): string {
  6621. const children = cg.getChildren(node.id)
  6622. .filter(c => c.kind !== 'import' && c.kind !== 'export')
  6623. .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
  6624. if (children.length === 0) return '';
  6625. const lines = [`**Members (${children.length}):**`, ''];
  6626. for (const c of children) {
  6627. const loc = c.startLine ? `:${c.startLine}` : '';
  6628. const sig = c.signature ? ` — \`${c.signature}\`` : '';
  6629. lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
  6630. }
  6631. return lines.join('\n');
  6632. }
  6633. private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
  6634. const location = node.startLine ? `:${node.startLine}` : '';
  6635. const lines: string[] = [
  6636. `**${node.name}** (${node.kind})`,
  6637. '',
  6638. `**Location:** ${node.filePath}${location}`,
  6639. ];
  6640. if (node.signature) {
  6641. lines.push(`**Signature:** \`${node.signature}\``);
  6642. }
  6643. // Only include docstring if it's short and useful
  6644. if (node.docstring && node.docstring.length < 200) {
  6645. lines.push('', node.docstring);
  6646. }
  6647. if (outline) {
  6648. lines.push('', outline, '',
  6649. `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
  6650. } else if (code) {
  6651. // Line-numbered (cat -n style, like codegraph_explore and Read) so the
  6652. // agent can cite/edit exact lines without re-Reading the file for them.
  6653. const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
  6654. lines.push('', '```' + node.language, numbered, '```');
  6655. }
  6656. return lines.join('\n');
  6657. }
  6658. private textResult(text: string): ToolResult {
  6659. return {
  6660. content: [{ type: 'text', text }],
  6661. };
  6662. }
  6663. private errorResult(message: string): ToolResult {
  6664. return {
  6665. content: [{ type: 'text', text: `Error: ${message}` }],
  6666. isError: true,
  6667. };
  6668. }
  6669. }