| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260 |
- /**
- * MCP Tool Definitions
- *
- * Defines the tools exposed by the CodeGraph MCP server.
- */
- import CodeGraph, { findNearestCodeGraphRoot } from '../index';
- import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
- import { createHash } from 'crypto';
- import {
- constants as fsConstants,
- closeSync,
- existsSync,
- lstatSync,
- openSync,
- readFileSync,
- writeSync,
- } from 'fs';
- import { clamp, validatePathWithinRoot, validateProjectPath } from '../utils';
- import { tmpdir } from 'os';
- import { join } from 'path';
- /** Maximum output length to prevent context bloat (characters) */
- const MAX_OUTPUT_LENGTH = 15000;
- /**
- * Maximum length for free-form string inputs (query, task, symbol).
- * Bounds memory and CPU when a buggy or hostile MCP client sends a
- * huge payload — without this an attacker could ship a 100MB string
- * and force a full FTS5 scan / OOM the server. 10 000 characters is
- * far beyond any realistic legitimate query.
- */
- const MAX_INPUT_LENGTH = 10_000;
- /**
- * Maximum length for path-like string inputs (projectPath, path
- * filter, glob pattern). Paths beyond a few thousand chars are
- * never legitimate and signal abuse or a bug upstream.
- */
- const MAX_PATH_LENGTH = 4_096;
- /**
- * Rust path roots that have no file-system equivalent — `crate` is the
- * current crate, `super` is the parent module, `self` is the current
- * module. Used by `matchesSymbol` to strip these before file-path
- * matching so `crate::configurator::stage_apply::run` resolves the
- * same as `configurator::stage_apply::run`.
- */
- const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
- /**
- * Node kinds that contain other symbols. For these, `codegraph_node` with
- * `includeCode=true` returns a structural outline (member names + signatures
- * + line numbers) instead of the full body, which for a large class is a
- * multi-thousand-character wall of source that bloats the agent's context.
- */
- const CONTAINER_NODE_KINDS = new Set<NodeKind>([
- 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
- ]);
- /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
- function lastQualifierPart(symbol: string): string {
- const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
- return parts[parts.length - 1] ?? symbol;
- }
- /**
- * Calculate the recommended number of codegraph_explore calls based on project size.
- * Larger codebases need more exploration calls to cover their surface area,
- * but smaller ones should use fewer to avoid unnecessary overhead.
- */
- export function getExploreBudget(fileCount: number): number {
- if (fileCount < 500) return 1;
- if (fileCount < 5000) return 2;
- if (fileCount < 15000) return 3;
- if (fileCount < 25000) return 4;
- return 5;
- }
- /**
- * Adaptive output budget for `codegraph_explore`, scaled to project size.
- *
- * Smaller codebases get a tighter total cap, fewer default files, smaller
- * per-file cap, and tighter clustering — so a focused query on a 100-file
- * project doesn't dump a whole file's worth of source into the agent's
- * context. Larger codebases keep the generous defaults because the
- * agent's native discovery cost (grep + find + many Reads) genuinely
- * dwarfs a fat explore call at that scale.
- *
- * Meta-text (relationships map, "additional relevant files" list,
- * completeness signal, budget note) is gated off for tiny projects
- * where one rich call is the whole story and the extra prose is just
- * overhead.
- *
- * Tier breakpoints mirror `getExploreBudget` so a project sits in the
- * same tier across both knobs.
- */
- export interface ExploreOutputBudget {
- /** Hard cap on total output characters. */
- maxOutputChars: number;
- /** Default `maxFiles` when the caller didn't specify one. */
- defaultMaxFiles: number;
- /** Cap on contiguous source returned per file (across all its clusters). */
- maxCharsPerFile: number;
- /** Cluster gap threshold in lines — tighter clustering on small projects. */
- gapThreshold: number;
- /** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */
- maxSymbolsInFileHeader: number;
- /** Max edges shown per relationship kind in the Relationships section. */
- maxEdgesPerRelationshipKind: number;
- /** Include the "Relationships" section. */
- includeRelationships: boolean;
- /** Include the "Additional relevant files (not shown)" trailing list. */
- includeAdditionalFiles: boolean;
- /** Include the "Complete source code is included above…" reminder. */
- includeCompletenessSignal: boolean;
- /** Include the explore-budget reminder at the end. */
- includeBudgetNote: boolean;
- }
- export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
- if (fileCount < 500) {
- return {
- maxOutputChars: 18000,
- defaultMaxFiles: 5,
- maxCharsPerFile: 3800,
- gapThreshold: 8,
- maxSymbolsInFileHeader: 6,
- maxEdgesPerRelationshipKind: 6,
- includeRelationships: true,
- includeAdditionalFiles: false,
- includeCompletenessSignal: false,
- includeBudgetNote: false,
- };
- }
- if (fileCount < 5000) {
- return {
- // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
- // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
- // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
- // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
- // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
- maxOutputChars: 28000,
- defaultMaxFiles: 10,
- maxCharsPerFile: 6500,
- gapThreshold: 12,
- maxSymbolsInFileHeader: 10,
- maxEdgesPerRelationshipKind: 10,
- includeRelationships: true,
- includeAdditionalFiles: true,
- includeCompletenessSignal: true,
- includeBudgetNote: true,
- };
- }
- if (fileCount < 15000) {
- return {
- maxOutputChars: 35000,
- defaultMaxFiles: 12,
- maxCharsPerFile: 7000,
- gapThreshold: 15,
- maxSymbolsInFileHeader: 15,
- maxEdgesPerRelationshipKind: 15,
- includeRelationships: true,
- includeAdditionalFiles: true,
- includeCompletenessSignal: true,
- includeBudgetNote: true,
- };
- }
- return {
- maxOutputChars: 38000,
- defaultMaxFiles: 14,
- maxCharsPerFile: 7000,
- gapThreshold: 15,
- maxSymbolsInFileHeader: 15,
- maxEdgesPerRelationshipKind: 15,
- includeRelationships: true,
- includeAdditionalFiles: true,
- includeCompletenessSignal: true,
- includeBudgetNote: true,
- };
- }
- /**
- * Whether `codegraph_explore` should prefix source lines with their line
- * numbers (cat -n style: `<num>\t<code>`).
- *
- * Line numbers let the agent cite `file:line` straight from the explore
- * payload instead of re-Reading the file just to find a line number — the
- * dominant residual cost on precise-tracing questions (#185 follow-up).
- *
- * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
- * A/B harness to measure the payload-cost vs. read-savings tradeoff).
- */
- function exploreLineNumbersEnabled(): boolean {
- return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
- }
- /**
- * Prefix each line of a source slice with its 1-based line number, matching
- * the Read tool's `cat -n` convention (number + tab) so the agent treats it
- * the same way it treats Read output.
- *
- * @param slice contiguous source text (already extracted from the file)
- * @param firstLineNumber the 1-based line number of the slice's first line
- */
- function numberSourceLines(slice: string, firstLineNumber: number): string {
- const out: string[] = [];
- const split = slice.split('\n');
- for (let i = 0; i < split.length; i++) {
- out.push(`${firstLineNumber + i}\t${split[i]}`);
- }
- return out.join('\n');
- }
- /**
- * Mark a Claude session as having consulted MCP tools.
- * This enables Grep/Glob/Bash commands that would otherwise be blocked.
- *
- * Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
- * tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
- * machine any other local user can pre-create `codegraph-consulted-<hash>` as
- * a symlink pointing at a file the victim owns. The old `writeFileSync` would
- * happily follow that link and overwrite the target's contents with the ISO
- * timestamp string (CWE-59). The session-id hash provides the predictability
- * gate, but it's defense-in-depth: if a session id ever surfaces in logs,
- * argv, or telemetry the attack becomes trivial, and the right fix is to not
- * follow links from /tmp paths in the first place.
- */
- function markSessionConsulted(sessionId: string): void {
- try {
- const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
- const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
- // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
- // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
- // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
- // drops it and openSync would follow the link. This lstat check closes that
- // gap cross-platform; ENOENT (path is free) falls through to create it.
- try {
- if (lstatSync(markerPath).isSymbolicLink()) return;
- } catch {
- // No existing entry (or stat failed) — nothing to refuse; proceed.
- }
- // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
- // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
- // mode 0o600 prevents readback by other local users (the marker payload is
- // benign, but narrowing the exposure costs nothing).
- const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
- const fd = openSync(markerPath, flags, 0o600);
- try {
- writeSync(fd, new Date().toISOString());
- } finally {
- closeSync(fd);
- }
- } catch {
- // Silently fail - don't break MCP on marker write failure. ELOOP from a
- // planted symlink lands here too, which is the intended behavior: refuse
- // to write rather than overwrite an attacker-chosen target.
- }
- }
- /**
- * MCP Tool definition
- */
- export interface ToolDefinition {
- name: string;
- description: string;
- inputSchema: {
- type: 'object';
- properties: Record<string, PropertySchema>;
- required?: string[];
- };
- }
- interface PropertySchema {
- type: string;
- description: string;
- enum?: string[];
- default?: unknown;
- }
- /**
- * Tool execution result
- */
- export interface ToolResult {
- content: Array<{
- type: 'text';
- text: string;
- }>;
- isError?: boolean;
- }
- /**
- * Common projectPath property for cross-project queries
- */
- const projectPathProperty: PropertySchema = {
- type: 'string',
- description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
- };
- /**
- * All CodeGraph MCP tools
- *
- * Designed for minimal context usage - use codegraph_context as the primary tool,
- * and only use other tools for targeted follow-up queries.
- *
- * All tools support cross-project queries via the optional `projectPath` parameter.
- */
- export const tools: ToolDefinition[] = [
- {
- name: 'codegraph_search',
- description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
- inputSchema: {
- type: 'object',
- properties: {
- query: {
- type: 'string',
- description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
- },
- kind: {
- type: 'string',
- description: 'Filter by node kind',
- enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
- },
- limit: {
- type: 'number',
- description: 'Maximum results (default: 10)',
- default: 10,
- },
- projectPath: projectPathProperty,
- },
- required: ['query'],
- },
- },
- {
- name: 'codegraph_context',
- description: 'PRIMARY TOOL — call this FIRST for any "how does X work", architecture, feature, or bug-context question. Composes search + node + callers + callees and returns entry points, related symbols, and key code in ONE call — usually enough to answer with no further search/Read/Grep. Prefer this over chaining codegraph_search + codegraph_node, and over codegraph_explore. NOTE: provides CODE context, not product requirements; for new features still clarify UX/edge cases with the user.',
- inputSchema: {
- type: 'object',
- properties: {
- task: {
- type: 'string',
- description: 'Description of the task, bug, or feature to build context for',
- },
- maxNodes: {
- type: 'number',
- description: 'Maximum symbols to include (default: 20)',
- default: 20,
- },
- includeCode: {
- type: 'boolean',
- description: 'Include code snippets for key symbols (default: true)',
- default: true,
- },
- projectPath: projectPathProperty,
- },
- required: ['task'],
- },
- },
- {
- name: 'codegraph_callers',
- description: 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.',
- inputSchema: {
- type: 'object',
- properties: {
- symbol: {
- type: 'string',
- description: 'Name of the function, method, or class to find callers for',
- },
- limit: {
- type: 'number',
- description: 'Maximum number of callers to return (default: 20)',
- default: 20,
- },
- projectPath: projectPathProperty,
- },
- required: ['symbol'],
- },
- },
- {
- name: 'codegraph_callees',
- description: 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.',
- inputSchema: {
- type: 'object',
- properties: {
- symbol: {
- type: 'string',
- description: 'Name of the function, method, or class to find callees for',
- },
- limit: {
- type: 'number',
- description: 'Maximum number of callees to return (default: 20)',
- default: 20,
- },
- projectPath: projectPathProperty,
- },
- required: ['symbol'],
- },
- },
- {
- name: 'codegraph_impact',
- description: 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.',
- inputSchema: {
- type: 'object',
- properties: {
- symbol: {
- type: 'string',
- description: 'Name of the symbol to analyze impact for',
- },
- depth: {
- type: 'number',
- description: 'How many levels of dependencies to traverse (default: 2)',
- default: 2,
- },
- projectPath: projectPathProperty,
- },
- required: ['symbol'],
- },
- },
- {
- name: 'codegraph_node',
- description: 'Get ONE symbol\'s details (location, signature, docstring) PLUS its TRAIL — what it calls and what calls it, each with file:line. Pass includeCode=true for source (functions return their body; containers return a member outline). Use this to WALK the call graph hop-by-hop — node a symbol, then node one of its trail entries — the structural, no-Read way to follow "what calls/triggers/handles X" across files. For a broad first overview of many symbols at once use codegraph_explore; use node to drill along a specific path from there. (If a trail is empty on a non-leaf, that hop is likely dynamic dispatch — read just that line.) Source returned with includeCode is the verbatim live file content — identical to Read.',
- inputSchema: {
- type: 'object',
- properties: {
- symbol: {
- type: 'string',
- description: 'Name of the symbol to get details for',
- },
- includeCode: {
- type: 'boolean',
- description: 'Include full source code (default: false to minimize context)',
- default: false,
- },
- projectPath: projectPathProperty,
- },
- required: ['symbol'],
- },
- },
- {
- name: 'codegraph_explore',
- description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts". The code it returns is the VERBATIM live file source (byte-for-byte identical to Read), line-numbered — not a summary; treat files it shows as already Read, no need to re-open them.',
- inputSchema: {
- type: 'object',
- properties: {
- query: {
- type: 'string',
- description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.',
- },
- maxFiles: {
- type: 'number',
- description: 'Maximum number of files to include source code from (default: 12)',
- default: 12,
- },
- projectPath: projectPathProperty,
- },
- required: ['query'],
- },
- },
- {
- name: 'codegraph_status',
- description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.',
- inputSchema: {
- type: 'object',
- properties: {
- projectPath: projectPathProperty,
- },
- },
- },
- {
- name: 'codegraph_files',
- description: 'REQUIRED for file/folder exploration. Get the project file structure from the CodeGraph index. Returns a tree view of all indexed files with metadata (language, symbol count). Much faster than Glob/filesystem scanning. Use this FIRST when exploring project structure, finding files, or understanding codebase organization.',
- inputSchema: {
- type: 'object',
- properties: {
- path: {
- type: 'string',
- description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
- },
- pattern: {
- type: 'string',
- description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
- },
- format: {
- type: 'string',
- description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
- enum: ['tree', 'flat', 'grouped'],
- default: 'tree',
- },
- includeMetadata: {
- type: 'boolean',
- description: 'Include file metadata like language and symbol count (default: true)',
- default: true,
- },
- maxDepth: {
- type: 'number',
- description: 'Maximum directory depth to show (default: unlimited)',
- },
- projectPath: projectPathProperty,
- },
- },
- },
- {
- name: 'codegraph_trace',
- description: 'Trace the CALL PATH between two symbols — "how does <from> reach/become <to>?" Returns the chain of functions from one to the other (each hop with file:line + the call-site line) in ONE call. This is something grep/Read structurally cannot do: there is no text pattern for "the path from A to B". Ideal for flow questions — how an update triggers a render, how a request reaches a handler, how a QuerySet becomes SQL. If no static path exists the chain likely breaks at dynamic dispatch (callbacks/descriptors/metaclasses); the tool says where and points you to codegraph_node to bridge it.',
- inputSchema: {
- type: 'object',
- properties: {
- from: {
- type: 'string',
- description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
- },
- to: {
- type: 'string',
- description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
- },
- projectPath: projectPathProperty,
- },
- required: ['from', 'to'],
- },
- },
- ];
- /**
- * Tool handler that executes tools against a CodeGraph instance
- *
- * Supports cross-project queries via the projectPath parameter.
- * Other projects are opened on-demand and cached for performance.
- */
- export class ToolHandler {
- // Cache of opened CodeGraph instances for cross-project queries
- private projectCache: Map<string, CodeGraph> = new Map();
- // The directory the server last searched for a default project. Surfaced in
- // the "not initialized" error so users can see why detection missed.
- private defaultProjectHint: string | null = null;
- constructor(private cg: CodeGraph | null) {}
- /**
- * Update the default CodeGraph instance (e.g. after lazy initialization)
- */
- setDefaultCodeGraph(cg: CodeGraph): void {
- this.cg = cg;
- }
- /**
- * Record the directory the server tried to resolve the default project from.
- * Used only to make the "no default project" error actionable.
- */
- setDefaultProjectHint(searchedPath: string): void {
- this.defaultProjectHint = searchedPath;
- }
- /**
- * Whether a default CodeGraph instance is available
- */
- hasDefaultCodeGraph(): boolean {
- return this.cg !== null;
- }
- /**
- * Get tool definitions with dynamic descriptions based on project size.
- * The codegraph_explore tool description includes a budget recommendation
- * scaled to the number of indexed files.
- */
- getTools(): ToolDefinition[] {
- if (!this.cg) return tools;
- try {
- const stats = this.cg.getStats();
- const budget = getExploreBudget(stats.fileCount);
- return tools.map(tool => {
- if (tool.name === 'codegraph_explore') {
- return {
- ...tool,
- description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
- };
- }
- return tool;
- });
- } catch {
- return tools;
- }
- }
- /**
- * Get CodeGraph instance for a project
- *
- * If projectPath is provided, opens that project's CodeGraph (cached).
- * Otherwise returns the default CodeGraph instance.
- *
- * Walks up parent directories to find the nearest .codegraph/ folder,
- * similar to how git finds .git/ directories.
- */
- private getCodeGraph(projectPath?: string): CodeGraph {
- if (!projectPath) {
- if (!this.cg) {
- const searched = this.defaultProjectHint ?? process.cwd();
- throw new Error(
- 'No CodeGraph project is loaded for this session.\n' +
- `Searched for a .codegraph/ directory starting from: ${searched}\n` +
- 'The index is likely fine — this is a working-directory detection issue: ' +
- "the MCP client launched the server outside your project and didn't report the " +
- 'workspace root. Fix it either way:\n' +
- ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
- ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
- );
- }
- return this.cg;
- }
- // Check cache first (using original path as key)
- if (this.projectCache.has(projectPath)) {
- return this.projectCache.get(projectPath)!;
- }
- // Reject sensitive system directories before opening. Only validate a
- // path that actually exists — a nested or not-yet-created sub-path of a
- // real project must still be allowed to resolve UP to its .codegraph/
- // root below (issue #238), so we don't run the existence-checking
- // validator on paths that are meant to walk up.
- if (existsSync(projectPath)) {
- const pathError = validateProjectPath(projectPath);
- if (pathError) {
- throw new Error(pathError);
- }
- }
- // Walk up parent directories to find nearest .codegraph/
- const resolvedRoot = findNearestCodeGraphRoot(projectPath);
- if (!resolvedRoot) {
- throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
- }
- // If the path resolves to the default project, reuse the already-open
- // default instance rather than opening a SECOND connection to the same DB.
- // A duplicate connection serializes reads against the watcher's auto-sync
- // writes; on the wasm backend (no WAL) that surfaces as intermittent
- // "database is locked" on concurrent tool calls. See issue #238. Deliberately
- // not cached under projectPath — the server owns and closes the default
- // instance, so routing it through projectCache.closeAll() would double-close it.
- if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
- return this.cg;
- }
- // Check if we already have this resolved root cached (different path, same project)
- if (this.projectCache.has(resolvedRoot)) {
- const cg = this.projectCache.get(resolvedRoot)!;
- // Cache under original path too for faster future lookups
- this.projectCache.set(projectPath, cg);
- return cg;
- }
- // Open and cache under both paths
- const cg = CodeGraph.openSync(resolvedRoot);
- this.projectCache.set(resolvedRoot, cg);
- if (projectPath !== resolvedRoot) {
- this.projectCache.set(projectPath, cg);
- }
- return cg;
- }
- /**
- * Close all cached project connections
- */
- closeAll(): void {
- for (const cg of this.projectCache.values()) {
- cg.close();
- }
- this.projectCache.clear();
- }
- /**
- * Validate that a value is a non-empty string within length bounds.
- *
- * The `maxLength` cap protects against MCP clients that ship huge
- * payloads (10MB+ query strings either by accident or maliciously).
- * Without this, a single oversized input can pin the FTS5 index or
- * exhaust memory before any real work runs.
- */
- private validateString(
- value: unknown,
- name: string,
- maxLength: number = MAX_INPUT_LENGTH
- ): string | ToolResult {
- if (typeof value !== 'string' || value.length === 0) {
- return this.errorResult(`${name} must be a non-empty string`);
- }
- if (value.length > maxLength) {
- return this.errorResult(
- `${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`
- );
- }
- return value;
- }
- /**
- * Validate an optional path-like string input. Returns the value if
- * valid (or undefined), or a ToolResult with the error.
- */
- private validateOptionalPath(
- value: unknown,
- name: string
- ): string | undefined | ToolResult {
- if (value === undefined || value === null) return undefined;
- if (typeof value !== 'string') {
- return this.errorResult(`${name} must be a string`);
- }
- if (value.length > MAX_PATH_LENGTH) {
- return this.errorResult(
- `${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`
- );
- }
- return value;
- }
- /**
- * Execute a tool by name
- */
- async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
- try {
- // Cross-cutting input validation. All tools accept an optional
- // `projectPath` and most accept either `query`, `task`, or
- // `symbol` — bound their lengths centrally so individual handlers
- // can stay focused on tool-specific logic.
- const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
- if (typeof pathCheck === 'object' && pathCheck !== undefined) {
- return pathCheck;
- }
- // The `path` and `pattern` properties used by codegraph_files are
- // also path-shaped — apply the same cap.
- if (args.path !== undefined) {
- const check = this.validateOptionalPath(args.path, 'path');
- if (typeof check === 'object' && check !== undefined) return check;
- }
- if (args.pattern !== undefined) {
- const check = this.validateOptionalPath(args.pattern, 'pattern');
- if (typeof check === 'object' && check !== undefined) return check;
- }
- switch (toolName) {
- case 'codegraph_search':
- return await this.handleSearch(args);
- case 'codegraph_context':
- return await this.handleContext(args);
- case 'codegraph_callers':
- return await this.handleCallers(args);
- case 'codegraph_callees':
- return await this.handleCallees(args);
- case 'codegraph_impact':
- return await this.handleImpact(args);
- case 'codegraph_explore':
- return await this.handleExplore(args);
- case 'codegraph_node':
- return await this.handleNode(args);
- case 'codegraph_status':
- return await this.handleStatus(args);
- case 'codegraph_files':
- return await this.handleFiles(args);
- case 'codegraph_trace':
- return await this.handleTrace(args);
- default:
- return this.errorResult(`Unknown tool: ${toolName}`);
- }
- } catch (err) {
- return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
- }
- }
- /**
- * Handle codegraph_search
- */
- private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
- const query = this.validateString(args.query, 'query');
- if (typeof query !== 'string') return query;
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const kind = args.kind as string | undefined;
- const rawLimit = Number(args.limit) || 10;
- const limit = clamp(rawLimit, 1, 100);
- const results = cg.searchNodes(query, {
- limit,
- kinds: kind ? [kind as NodeKind] : undefined,
- });
- if (results.length === 0) {
- return this.textResult(`No results found for "${query}"`);
- }
- const formatted = this.formatSearchResults(results);
- return this.textResult(this.truncateOutput(formatted));
- }
- /**
- * Handle codegraph_context
- */
- private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
- const task = this.validateString(args.task, 'task');
- if (typeof task !== 'string') return task;
- // Mark session as consulted (enables Grep/Glob/Bash)
- const sessionId = process.env.CLAUDE_SESSION_ID;
- if (sessionId) {
- markSessionConsulted(sessionId);
- }
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const maxNodes = (args.maxNodes as number) || 20;
- const includeCode = args.includeCode !== false;
- const context = await cg.buildContext(task, {
- maxNodes,
- includeCode,
- format: 'markdown',
- });
- // Detect if this looks like a feature request (vs bug fix or exploration)
- const isFeatureQuery = this.looksLikeFeatureRequest(task);
- const reminder = isFeatureQuery
- ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
- : '';
- // buildContext returns string when format is 'markdown'
- if (typeof context === 'string') {
- return this.textResult(this.truncateOutput(context + reminder));
- }
- // If it returns TaskContext, format it
- return this.textResult(this.truncateOutput(this.formatTaskContext(context) + reminder));
- }
- /**
- * Heuristic to detect if a query looks like a feature request
- */
- private looksLikeFeatureRequest(task: string): boolean {
- const featureKeywords = [
- 'add', 'create', 'implement', 'build', 'enable', 'allow',
- 'new feature', 'support for', 'ability to', 'want to',
- 'should be able', 'need to add', 'swap', 'edit', 'modify'
- ];
- const bugKeywords = [
- 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
- 'not working', 'fails', 'undefined', 'null'
- ];
- const explorationKeywords = [
- 'how does', 'where is', 'what is', 'find', 'show me',
- 'explain', 'understand', 'explore'
- ];
- const lowerTask = task.toLowerCase();
- // If it's clearly a bug or exploration, not a feature
- if (bugKeywords.some(k => lowerTask.includes(k))) return false;
- if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
- // If it matches feature keywords, it's likely a feature request
- return featureKeywords.some(k => lowerTask.includes(k));
- }
- /**
- * Handle codegraph_callers
- */
- private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
- const symbol = this.validateString(args.symbol, 'symbol');
- if (typeof symbol !== 'string') return symbol;
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const limit = clamp((args.limit as number) || 20, 1, 100);
- const allMatches = this.findAllSymbols(cg, symbol);
- if (allMatches.nodes.length === 0) {
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
- }
- // Aggregate callers across all matching symbols
- const seen = new Set<string>();
- const allCallers: Node[] = [];
- for (const node of allMatches.nodes) {
- for (const c of cg.getCallers(node.id)) {
- if (!seen.has(c.node.id)) {
- seen.add(c.node.id);
- allCallers.push(c.node);
- }
- }
- }
- if (allCallers.length === 0) {
- return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
- }
- const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
- return this.textResult(this.truncateOutput(formatted));
- }
- /**
- * Handle codegraph_callees
- */
- private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
- const symbol = this.validateString(args.symbol, 'symbol');
- if (typeof symbol !== 'string') return symbol;
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const limit = clamp((args.limit as number) || 20, 1, 100);
- const allMatches = this.findAllSymbols(cg, symbol);
- if (allMatches.nodes.length === 0) {
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
- }
- // Aggregate callees across all matching symbols
- const seen = new Set<string>();
- const allCallees: Node[] = [];
- for (const node of allMatches.nodes) {
- for (const c of cg.getCallees(node.id)) {
- if (!seen.has(c.node.id)) {
- seen.add(c.node.id);
- allCallees.push(c.node);
- }
- }
- }
- if (allCallees.length === 0) {
- return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
- }
- const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
- return this.textResult(this.truncateOutput(formatted));
- }
- /**
- * Handle codegraph_impact
- */
- private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
- const symbol = this.validateString(args.symbol, 'symbol');
- if (typeof symbol !== 'string') return symbol;
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const depth = clamp((args.depth as number) || 2, 1, 10);
- const allMatches = this.findAllSymbols(cg, symbol);
- if (allMatches.nodes.length === 0) {
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
- }
- // Aggregate impact across all matching symbols
- const mergedNodes = new Map<string, Node>();
- const mergedEdges: Edge[] = [];
- const seenEdges = new Set<string>();
- for (const node of allMatches.nodes) {
- const impact = cg.getImpactRadius(node.id, depth);
- for (const [id, n] of impact.nodes) {
- mergedNodes.set(id, n);
- }
- for (const e of impact.edges) {
- const key = `${e.source}->${e.target}:${e.kind}`;
- if (!seenEdges.has(key)) {
- seenEdges.add(key);
- mergedEdges.push(e);
- }
- }
- }
- const mergedImpact = {
- nodes: mergedNodes,
- edges: mergedEdges,
- roots: allMatches.nodes.map(n => n.id),
- };
- const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
- return this.textResult(this.truncateOutput(formatted));
- }
- /**
- * Handle codegraph_trace — shortest CALL PATH between two symbols.
- *
- * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
- * each hop annotated with file:line and the call-site line. This is the
- * capability grep/Read structurally cannot provide. When no static path
- * exists, the chain has almost certainly broken at dynamic dispatch
- * (callbacks, descriptors, metaclasses) — we say so and surface the start
- * symbol's outgoing calls so the agent bridges the one missing hop with
- * codegraph_node rather than blindly reading.
- */
- private async handleTrace(args: Record<string, unknown>): Promise<ToolResult> {
- const from = this.validateString(args.from, 'from');
- if (typeof from !== 'string') return from;
- const to = this.validateString(args.to, 'to');
- if (typeof to !== 'string') return to;
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const fromMatches = this.findAllSymbols(cg, from);
- if (fromMatches.nodes.length === 0) return this.textResult(`Symbol "${from}" not found in the codebase`);
- const toMatches = this.findAllSymbols(cg, to);
- if (toMatches.nodes.length === 0) return this.textResult(`Symbol "${to}" not found in the codebase`);
- // Trace along call edges only — a true call path. Names can map to several
- // nodes, so try a few from×to candidate pairs until a usable path turns up.
- //
- // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
- // is almost always a spurious wander through unrelated code (django's
- // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
- // the real execution flow — and a confident-but-wrong 15-hop trace is worse
- // than none. Over-cap paths are rejected and reported as "no direct path"
- // (which, on real code, means the flow breaks at dynamic dispatch).
- const edgeKinds: Edge['kind'][] = ['calls'];
- const MAX_HOPS = 7;
- const fromTry = fromMatches.nodes.slice(0, 3);
- const toTry = toMatches.nodes.slice(0, 3);
- let path: Array<{ node: Node; edge: Edge | null }> | null = null;
- let overCap: Array<{ node: Node; edge: Edge | null }> | null = null;
- for (const f of fromTry) {
- for (const t of toTry) {
- const p = cg.findPath(f.id, t.id, edgeKinds);
- if (!p || p.length <= 1) continue;
- if (p.length <= MAX_HOPS) { path = p; break; }
- if (!overCap || p.length < overCap.length) overCap = p;
- }
- if (path) break;
- }
- if (!path) {
- // No static path — almost always a dynamic-dispatch break. Surface the
- // start symbol's outgoing calls so the agent can bridge the gap.
- const start = fromTry[0]!;
- const callees = cg.getCallees(start.id).slice(0, 10)
- .map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
- const lines = [
- `No direct call path from "${from}" to "${to}".`,
- '',
- (overCap
- ? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
- : '') +
- 'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
- 'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
- `Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
- '(includeCode=true) — its body usually shows the dynamic call to follow next.',
- ];
- if (callees.length > 0) {
- lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
- }
- return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
- }
- const lines: string[] = [`## Trace: ${from} → ${to}`, '', `${path.length} hops:`, ''];
- // Inline the evidence each hop needs so the agent doesn't Read/Grep to get it:
- // the call-site source line for static calls, and — for dynamic-dispatch hops
- // bridged by callback synthesis — where the callback was registered. (This is
- // exactly what agents grepped for under a Read-0 constraint.)
- const fileCache = new Map<string, string[]>();
- for (let i = 0; i < path.length; i++) {
- const step = path[i]!;
- if (step.edge) {
- const synth = this.synthEdgeNote(step.edge);
- if (synth) {
- lines.push(` ↓ ${synth.label}`);
- if (synth.registeredAt) {
- const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
- lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
- }
- } else {
- // The call happens in the PREVIOUS hop's file at edge.line.
- const prev = path[i - 1];
- const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
- const callSrc = this.sourceLineAt(cg, ref, fileCache);
- lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
- }
- }
- lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
- }
- lines.push('', '> Each hop shows its call-site source line (and, for dynamic-dispatch hops, where the callback was registered) — no Read needed. codegraph_node a hop only for its full body.');
- return this.textResult(this.truncateOutput(lines.join('\n')));
- }
- /**
- * Describe a synthesized (dynamic-dispatch) edge for human output: how the
- * callback was wired up — the bridge static parsing can't see. Returns null
- * for ordinary static edges. Used by trace + the node trail so a synthesized
- * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
- */
- private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
- if (!edge || edge.provenance !== 'heuristic') return null;
- const m = edge.metadata as Record<string, unknown> | undefined;
- const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
- const at = registeredAt ? ` @${registeredAt}` : '';
- if (m?.synthesizedBy === 'callback') {
- const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
- const field = m.field ? ` on .${String(m.field)}` : '';
- return {
- label: `callback — registered via ${via}${field} (dynamic dispatch)`,
- compact: `dynamic: callback via ${via}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'event-emitter') {
- const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
- return {
- label: `event ${ev} — emit → handler (dynamic dispatch)`,
- compact: `dynamic: event ${ev}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'react-render') {
- return {
- label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
- compact: `dynamic: React re-render via setState${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'jsx-render') {
- const child = m.via ? `<${String(m.via)}>` : 'a child component';
- return {
- label: `renders ${child} (JSX child — dynamic dispatch)`,
- compact: `dynamic: renders ${child}`,
- registeredAt,
- };
- }
- return null;
- }
- /**
- * Read one trimmed source line at "relpath:line" (relative to the project
- * root). `cache` holds split file contents so a multi-hop trace reads each
- * file at most once. Returns null if the file/line can't be resolved.
- */
- private sourceLineAt(cg: CodeGraph, ref: string | undefined, cache: Map<string, string[]>): string | null {
- if (!ref) return null;
- const i = ref.lastIndexOf(':');
- if (i < 0) return null;
- const filePath = ref.slice(0, i);
- const line = parseInt(ref.slice(i + 1), 10);
- if (!Number.isFinite(line) || line < 1) return null;
- let fileLines = cache.get(filePath);
- if (!fileLines) {
- const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
- if (!abs || !existsSync(abs)) return null;
- try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
- cache.set(filePath, fileLines);
- }
- const raw = fileLines[line - 1];
- if (raw == null) return null;
- const t = raw.trim();
- return t.length > 160 ? t.slice(0, 157) + '…' : t;
- }
- /**
- * Handle codegraph_explore — deep exploration in a single call
- *
- * Strategy: find relevant symbols via graph traversal, group by file,
- * then read contiguous file sections covering all symbols per file.
- * This replaces multiple codegraph_node + Read calls.
- *
- * Output size is adaptive to project file count via
- * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
- * tax on small projects while earning its keep on large ones.
- */
- private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
- const query = this.validateString(args.query, 'query');
- if (typeof query !== 'string') return query;
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const projectRoot = cg.getProjectRoot();
- // Resolve adaptive output budget from project size. Falls back to the
- // largest-tier defaults if stats aren't available, which preserves
- // pre-#185 behavior for callers that hit the rare stats failure.
- let budget: ExploreOutputBudget;
- try {
- budget = getExploreOutputBudget(cg.getStats().fileCount);
- } catch {
- budget = getExploreOutputBudget(Infinity);
- }
- const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
- // Step 1: Find relevant context with generous parameters.
- // Use a large maxNodes budget — explore has its own 35k char output limit
- // that prevents context bloat, so more nodes just means better coverage
- // across entry points (especially for large files like Svelte components).
- const subgraph = await cg.findRelevantContext(query, {
- searchLimit: 8,
- traversalDepth: 3,
- maxNodes: 200,
- minScore: 0.2,
- });
- if (subgraph.nodes.size === 0) {
- return this.textResult(`No relevant code found for "${query}"`);
- }
- // Graph-aware glue: findRelevantContext builds the subgraph from name/text
- // search, so a method that BRIDGES named symbols — e.g. App.tsx's
- // triggerRender, which calls the named triggerUpdate — is never a search hit
- // and gets missed, forcing the agent to Read the file to trace it. Pull in
- // the callers/callees of the entry (root) nodes, but ONLY those that live in
- // files the subgraph already surfaces (where the agent reads to fill gaps),
- // so we add wiring without dragging in unrelated files. These get an
- // importance boost below so they survive the per-file cluster budget.
- const glueNodeIds = new Set<string>();
- const subgraphFiles = new Set<string>();
- for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
- const GLUE_NODE_CAP = 60;
- for (const rootId of subgraph.roots) {
- if (glueNodeIds.size >= GLUE_NODE_CAP) break;
- let neighbors: Node[] = [];
- try {
- neighbors = [
- ...cg.getCallers(rootId).map(c => c.node),
- ...cg.getCallees(rootId).map(c => c.node),
- ];
- } catch {
- continue;
- }
- for (const nb of neighbors) {
- if (glueNodeIds.size >= GLUE_NODE_CAP) break;
- if (subgraph.nodes.has(nb.id)) continue;
- if (!subgraphFiles.has(nb.filePath)) continue;
- subgraph.nodes.set(nb.id, nb);
- glueNodeIds.add(nb.id);
- }
- }
- // Step 2: Group nodes by file, score by relevance
- const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
- const entryNodeIds = new Set(subgraph.roots);
- // Build a set of nodes directly connected to entry points (depth 1)
- const connectedToEntry = new Set<string>();
- for (const edge of subgraph.edges) {
- if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
- if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
- }
- for (const node of subgraph.nodes.values()) {
- // Skip import/export nodes — they add noise without information
- if (node.kind === 'import' || node.kind === 'export') continue;
- const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
- group.nodes.push(node);
- // Score: entry point nodes worth 10, directly connected worth 3, others worth 1
- if (entryNodeIds.has(node.id)) {
- group.score += 10;
- } else if (connectedToEntry.has(node.id)) {
- group.score += 3;
- } else {
- group.score += 1;
- }
- fileGroups.set(node.filePath, group);
- }
- // Only include files that have entry points or nodes directly connected to entry points
- const relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
- // Extract query terms for relevance checking
- const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
- // Sort files: highest relevance first, deprioritize low-value files
- const sortedFiles = relevantFiles.sort((a, b) => {
- const aPath = a[0].toLowerCase();
- const bPath = b[0].toLowerCase();
- // Check if any node name or file path relates to query terms
- const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
- const fp = filePath.toLowerCase();
- if (queryTerms.some(t => fp.includes(t))) return true;
- return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
- };
- const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
- const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
- if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
- // Deprioritize test files, icon files, and i18n files
- const isLowValue = (p: string) =>
- /\/(tests?|__tests?__|spec)\//i.test(p) ||
- /\bicons?\b/i.test(p) ||
- /\bi18n\b/i.test(p);
- const aLow = isLowValue(aPath);
- const bLow = isLowValue(bPath);
- if (aLow !== bLow) return aLow ? 1 : -1;
- if (a[1].score !== b[1].score) return b[1].score - a[1].score;
- return b[1].nodes.length - a[1].nodes.length;
- });
- // Step 3: Build relationship map
- const lines: string[] = [
- `## Exploration: ${query}`,
- '',
- `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
- '',
- ];
- // Relationship map — show how symbols connect
- const significantEdges = subgraph.edges.filter(e =>
- e.kind !== 'contains' // skip contains — it's implied by file grouping
- );
- if (budget.includeRelationships && significantEdges.length > 0) {
- lines.push('### Relationships');
- lines.push('');
- // Group edges by kind for readability
- const byKind = new Map<string, Array<{ source: string; target: string }>>();
- for (const edge of significantEdges) {
- const sourceNode = subgraph.nodes.get(edge.source);
- const targetNode = subgraph.nodes.get(edge.target);
- if (!sourceNode || !targetNode) continue;
- const group = byKind.get(edge.kind) || [];
- group.push({ source: sourceNode.name, target: targetNode.name });
- byKind.set(edge.kind, group);
- }
- for (const [kind, edges] of byKind) {
- const cap = budget.maxEdgesPerRelationshipKind;
- const shown = edges.slice(0, cap);
- lines.push(`**${kind}:**`);
- for (const e of shown) {
- lines.push(`- ${e.source} → ${e.target}`);
- }
- if (edges.length > cap) {
- lines.push(`- ... and ${edges.length - cap} more`);
- }
- lines.push('');
- }
- }
- // Step 4: Read contiguous file sections
- lines.push('### Source Code');
- lines.push('');
- 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.');
- lines.push('');
- let totalChars = lines.join('\n').length;
- let filesIncluded = 0;
- let anyFileTrimmed = false;
- for (const [filePath, group] of sortedFiles) {
- if (filesIncluded >= maxFiles) break;
- if (totalChars > budget.maxOutputChars * 0.9) break;
- const absPath = validatePathWithinRoot(projectRoot, filePath);
- if (!absPath || !existsSync(absPath)) continue;
- let fileContent: string;
- try {
- fileContent = readFileSync(absPath, 'utf-8');
- } catch {
- continue;
- }
- const fileLines = fileContent.split('\n');
- const lang = group.nodes[0]?.language || '';
- // Whole-small-file rule: if a relevant file is small enough to afford,
- // return it ENTIRELY instead of clustering. Clustering exists to tame
- // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
- // lossy subset of a file the agent will just Read in full anyway — costing
- // a round-trip and a re-read every later turn. Reserve clustering for files
- // too big to ship whole. Still bounded by the total maxOutputChars check.
- const WHOLE_FILE_MAX_LINES = 220;
- const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
- if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
- const body = fileContent.replace(/\n+$/, '');
- let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
- const uniqSymbols = [...new Set(
- group.nodes
- .filter(n => n.kind !== 'import' && n.kind !== 'export')
- .map(n => `${n.name}(${n.kind})`)
- )];
- const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
- const omitted = uniqSymbols.length - headerNames.length;
- const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
- if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
- const remaining = budget.maxOutputChars - totalChars - 200;
- if (remaining < 500) break;
- wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
- anyFileTrimmed = true;
- }
- lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
- totalChars += wholeSection.length + 200;
- filesIncluded++;
- continue;
- }
- // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
- // Sort by start line, then merge overlapping/adjacent ranges (within the
- // adaptive gap threshold). Include both node ranges AND edge source
- // locations so template sections with component usages/calls are
- // covered (not just script block symbols).
- //
- // Each range carries an `importance` score so we can rank clusters
- // when the per-file budget forces us to drop some: entry-point nodes
- // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
- // bare edge-source lines 2 (less than a connected node but more than
- // a peripheral one — they hint at a reference but aren't a definition).
- // Container kinds whose body can span most/all of a file. When such a
- // node covers most of the file we drop it from the ranges: keeping it
- // would merge every method inside it into one giant cluster spanning
- // the whole file, which then tail-trims down to just the container's
- // opening lines (its header/declarations) and buries the methods the
- // query actually asked about (#185 follow-up — Session.swift in
- // Alamofire is the canonical case: the `Session` class spans ~1,400
- // lines). We want the granular symbols inside, not the envelope.
- const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
- const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
- .filter(n => n.startLine > 0 && n.endLine > 0)
- // Drop whole-file envelope nodes (containers covering >50% of the file).
- .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
- .map(n => {
- let importance = 1;
- if (entryNodeIds.has(n.id)) importance = 10;
- else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
- else if (connectedToEntry.has(n.id)) importance = 3;
- return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
- });
- // Add edge source locations in this file — captures template references
- // (component usages, event handlers) that aren't nodes themselves.
- // Query edges directly from the DB (not just the subgraph) because BFS
- // traversal may have pruned template reference targets due to node budget.
- const edgeLines = new Set<string>(); // dedup by "line:name"
- for (const node of group.nodes) {
- const outgoing = cg.getOutgoingEdges(node.id);
- for (const edge of outgoing) {
- if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
- const key = `${edge.line}:${edge.target}`;
- if (edgeLines.has(key)) continue;
- edgeLines.add(key);
- // Look up target name from subgraph first, fall back to edge kind
- const targetNode = subgraph.nodes.get(edge.target);
- const targetName = targetNode?.name ?? edge.kind;
- ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
- }
- }
- ranges.sort((a, b) => a.start - b.start);
- if (ranges.length === 0) continue;
- const gapThreshold = budget.gapThreshold;
- const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
- let current = {
- start: ranges[0]!.start,
- end: ranges[0]!.end,
- symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
- score: ranges[0]!.importance,
- maxImportance: ranges[0]!.importance,
- };
- for (let i = 1; i < ranges.length; i++) {
- const r = ranges[i]!;
- if (r.start <= current.end + gapThreshold) {
- current.end = Math.max(current.end, r.end);
- current.symbols.push(`${r.name}(${r.kind})`);
- current.score += r.importance;
- current.maxImportance = Math.max(current.maxImportance, r.importance);
- } else {
- clusters.push(current);
- current = {
- start: r.start,
- end: r.end,
- symbols: [`${r.name}(${r.kind})`],
- score: r.importance,
- maxImportance: r.importance,
- };
- }
- }
- clusters.push(current);
- // Build file section output from clusters, capped by per-file budget.
- // The pathological case (#185): a file like Session.swift where every
- // method is adjacent collapses into one cluster spanning the whole
- // file, and dumping that into the agent's context is most of the
- // token cost on small projects. We pick clusters in priority order
- // until the per-file char cap is hit. Truly enormous single clusters
- // get tail-trimmed with a marker.
- const contextPadding = 3;
- const withLineNumbers = exploreLineNumbersEnabled();
- const buildSection = (c: { start: number; end: number }): string => {
- const startIdx = Math.max(0, c.start - 1 - contextPadding);
- const endIdx = Math.min(fileLines.length, c.end + contextPadding);
- const slice = fileLines.slice(startIdx, endIdx).join('\n');
- // startIdx is 0-based, so the slice's first line is line startIdx + 1.
- return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
- };
- // Language-neutral separator (no `//` — not a comment in Python, Ruby,
- // etc.). With line numbers on, the line-number jump also signals the gap.
- const GAP_MARKER = '\n\n... (gap) ...\n\n';
- // Rank clusters for inclusion under the per-file cap. Entry-point
- // clusters come first: a cluster containing a query entry point
- // (importance 10) must outrank a dense block of mere declarations,
- // otherwise on a large file like Session.swift the top-of-file class
- // header + property list (many adjacent low-importance nodes, high
- // density) wins the budget and buries the actual methods the query
- // asked about (perform/didCreateURLRequest/task live deep in the
- // file). Within the same importance tier, prefer density (score per
- // line) so we still favor focused clusters over sprawling ones, then
- // smaller span as a cheap-to-include tiebreak.
- const rankedClusters = clusters
- .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
- .sort((a, b) => {
- if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
- const densityA = a.c.score / a.span;
- const densityB = b.c.score / b.span;
- if (densityB !== densityA) return densityB - densityA;
- if (b.c.score !== a.c.score) return b.c.score - a.c.score;
- return a.span - b.span;
- });
- const chosenIndices = new Set<number>();
- let projectedChars = 0;
- for (const rc of rankedClusters) {
- const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
- // Always take the top-ranked cluster, even if oversize, so we don't
- // return an empty file section (agent would then re-Read the file,
- // negating the savings).
- if (chosenIndices.size === 0) {
- chosenIndices.add(rc.idx);
- projectedChars += sectionLen;
- continue;
- }
- if (projectedChars + sectionLen > budget.maxCharsPerFile) continue;
- chosenIndices.add(rc.idx);
- projectedChars += sectionLen;
- }
- // Emit chosen clusters in source order so the file reads top-to-bottom.
- let fileSection = '';
- const allSymbols: string[] = [];
- let fileTrimmed = false;
- for (let i = 0; i < clusters.length; i++) {
- if (!chosenIndices.has(i)) continue;
- const cluster = clusters[i]!;
- const section = buildSection(cluster);
- if (fileSection.length > 0) fileSection += GAP_MARKER;
- fileSection += section;
- allSymbols.push(...cluster.symbols);
- }
- // If a single chosen cluster is still oversize (long monolithic
- // function), tail-trim it. Better one trimmed view than nothing.
- if (fileSection.length > budget.maxCharsPerFile) {
- fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
- fileTrimmed = true;
- }
- if (chosenIndices.size < clusters.length || fileTrimmed) {
- anyFileTrimmed = true;
- }
- // Dedupe + cap the symbols list shown in the per-file header. Some
- // files (Session.swift in Alamofire) produced 3.4KB symbol lists
- // from cluster scoring + edge-source lines, dwarfing the per-file
- // body cap. Show top names by frequency, with a "+N more" tail.
- const symbolCounts = new Map<string, number>();
- for (const s of allSymbols) {
- symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
- }
- const sortedSymbols = [...symbolCounts.entries()]
- .sort((a, b) => b[1] - a[1])
- .map(([name]) => name);
- const headerCap = budget.maxSymbolsInFileHeader;
- const headerSymbols = sortedSymbols.slice(0, headerCap);
- const omittedCount = sortedSymbols.length - headerSymbols.length;
- const headerSuffix = omittedCount > 0
- ? `${headerSymbols.join(', ')}, +${omittedCount} more`
- : headerSymbols.join(', ');
- const fileHeader = `#### ${filePath} — ${headerSuffix}`;
- // Respect the total output cap on a file-by-file basis.
- if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
- const remaining = budget.maxOutputChars - totalChars - 200;
- if (remaining < 500) break;
- const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
- lines.push(fileHeader);
- lines.push('');
- lines.push('```' + lang);
- lines.push(trimmed);
- lines.push('```');
- lines.push('');
- totalChars += trimmed.length + 200;
- filesIncluded++;
- anyFileTrimmed = true;
- break;
- }
- lines.push(fileHeader);
- lines.push('');
- lines.push('```' + lang);
- lines.push(fileSection);
- lines.push('```');
- lines.push('');
- totalChars += fileSection.length + 200;
- filesIncluded++;
- }
- // Add remaining files as references (from both relevant and peripheral files).
- // Small projects (per budget) skip this — the relevant story already fits
- // in the source section, and a trailing pointer list is pure overhead.
- if (budget.includeAdditionalFiles) {
- const remainingRelevant = sortedFiles.slice(filesIncluded);
- const peripheralFiles = [...fileGroups.entries()]
- .filter(([, group]) => group.score < 3)
- .sort((a, b) => b[1].score - a[1].score);
- const remainingFiles = [...remainingRelevant, ...peripheralFiles];
- if (remainingFiles.length > 0) {
- lines.push('### Not shown above — explore these names for their source');
- lines.push('');
- for (const [filePath, group] of remainingFiles.slice(0, 10)) {
- const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
- lines.push(`- ${filePath}: ${symbols}`);
- }
- if (remainingFiles.length > 10) {
- lines.push(`- ... and ${remainingFiles.length - 10} more files`);
- }
- }
- }
- // Add completeness signal so agents know they don't need to re-read these files.
- // On small projects the budget gates this off — but if we actually had to
- // trim or drop clusters, surface a brief note so the agent knows it can
- // still Read for more detail.
- if (budget.includeCompletenessSignal) {
- lines.push('');
- lines.push('---');
- lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
- } else if (anyFileTrimmed) {
- lines.push('');
- lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
- }
- // Add explore budget note based on project size
- if (budget.includeBudgetNote) {
- try {
- const stats = cg.getStats();
- const callBudget = getExploreBudget(stats.fileCount);
- lines.push('');
- lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
- } catch {
- // Stats unavailable — skip budget note
- }
- }
- // Hard-cap to the adaptive budget. The per-file loop bounds the source
- // sections, but the relationship map, additional-files list, and
- // completeness/budget notes can still push the assembled output past
- // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
- // payload persists in the agent's context and is re-read as cache-input
- // on every subsequent turn, so the overrun is paid many times over.
- const output = lines.join('\n');
- if (output.length > budget.maxOutputChars) {
- const cut = output.slice(0, budget.maxOutputChars);
- const lastNewline = cut.lastIndexOf('\n');
- const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
- return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
- }
- return this.textResult(output);
- }
- /**
- * Handle codegraph_node
- */
- private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
- const symbol = this.validateString(args.symbol, 'symbol');
- if (typeof symbol !== 'string') return symbol;
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- // Default to false to minimize context usage
- const includeCode = args.includeCode === true;
- const match = this.findSymbol(cg, symbol);
- if (!match) {
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
- }
- let code: string | null = null;
- let outline: string | null = null;
- if (includeCode) {
- // For container symbols (class/interface/struct/…), the full body is the
- // sum of every method body — a wall of source (e.g. a 10k-char class)
- // that bloats context and is rarely needed in full. Return a structural
- // outline (members + signatures + line numbers) instead; the agent can
- // Read or codegraph_node a specific method for its body. Leaf symbols
- // (function/method/etc.) return their full body as before.
- if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
- outline = this.buildContainerOutline(cg, match.node);
- }
- if (!outline) {
- code = await cg.getCode(match.node.id);
- }
- }
- const trail = this.formatTrail(cg, match.node);
- const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
- return this.textResult(this.truncateOutput(formatted));
- }
- /**
- * Build the "trail" for a symbol: its direct callees (what it calls) and
- * callers (what calls it), each with file:line — so codegraph_node doubles as
- * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
- * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
- * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
- * dynamic dispatch the static graph couldn't resolve — that absence is itself
- * a signal (read that one hop) rather than a dead end.
- */
- private formatTrail(cg: CodeGraph, node: Node): string {
- const TRAIL_CAP = 12;
- const fmt = (e: { node: Node; edge: Edge }) => {
- const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
- const synth = this.synthEdgeNote(e.edge);
- return synth ? `${base} [${synth.compact}]` : base;
- };
- const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
- const seen = new Set<string>([node.id]);
- const out: Array<{ node: Node; edge: Edge }> = [];
- for (const e of edges) {
- if (seen.has(e.node.id)) continue;
- seen.add(e.node.id);
- out.push(e);
- }
- return out;
- };
- const callees = collect(cg.getCallees(node.id));
- const callers = collect(cg.getCallers(node.id));
- if (callees.length === 0 && callers.length === 0) return '';
- const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
- if (callees.length > 0) {
- lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
- }
- if (callers.length > 0) {
- lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
- }
- return lines.join('\n');
- }
- /**
- * Handle codegraph_status
- */
- private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const stats = cg.getStats();
- const lines: string[] = [
- '## CodeGraph Status',
- '',
- `**Files indexed:** ${stats.fileCount}`,
- `**Total nodes:** ${stats.nodeCount}`,
- `**Total edges:** ${stats.edgeCount}`,
- `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
- ];
- // Surface the active SQLite backend (node:sqlite, Node's built-in real
- // SQLite — full WAL + FTS5, no native build).
- lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
- // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
- // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
- // everywhere, so a non-wal mode means the filesystem can't (network/
- // virtualized mounts, WSL2 /mnt). See issue #238.
- const journalMode = cg.getJournalMode();
- if (journalMode === 'wal') {
- lines.push(`**Journal mode:** wal (concurrent reads safe)`);
- } else {
- lines.push(
- `**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
- `can block on a concurrent write (WAL appears unsupported on this filesystem)`
- );
- }
- lines.push('', '### Nodes by Kind:');
- for (const [kind, count] of Object.entries(stats.nodesByKind)) {
- if ((count as number) > 0) {
- lines.push(`- ${kind}: ${count}`);
- }
- }
- lines.push('', '### Languages:');
- for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
- if ((count as number) > 0) {
- lines.push(`- ${lang}: ${count}`);
- }
- }
- return this.textResult(lines.join('\n'));
- }
- /**
- * Handle codegraph_files - get project file structure from the index
- */
- private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
- const cg = this.getCodeGraph(args.projectPath as string | undefined);
- const pathFilter = args.path as string | undefined;
- const pattern = args.pattern as string | undefined;
- const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
- const includeMetadata = args.includeMetadata !== false;
- const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
- // Get all files from the index
- const allFiles = cg.getFiles();
- if (allFiles.length === 0) {
- return this.textResult('No files indexed. Run `codegraph index` first.');
- }
- // Filter by path prefix
- let files = pathFilter
- ? allFiles.filter(f => f.path.startsWith(pathFilter) || f.path.startsWith('./' + pathFilter))
- : allFiles;
- // Filter by glob pattern
- if (pattern) {
- const regex = this.globToRegex(pattern);
- files = files.filter(f => regex.test(f.path));
- }
- if (files.length === 0) {
- return this.textResult(`No files found matching the criteria.`);
- }
- // Format output
- let output: string;
- switch (format) {
- case 'flat':
- output = this.formatFilesFlat(files, includeMetadata);
- break;
- case 'grouped':
- output = this.formatFilesGrouped(files, includeMetadata);
- break;
- case 'tree':
- default:
- output = this.formatFilesTree(files, includeMetadata, maxDepth);
- break;
- }
- return this.textResult(this.truncateOutput(output));
- }
- /**
- * Convert glob pattern to regex
- */
- private globToRegex(pattern: string): RegExp {
- const escaped = pattern
- .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
- .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
- .replace(/\*/g, '[^/]*') // * matches anything except /
- .replace(/\?/g, '[^/]') // ? matches single char except /
- .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
- return new RegExp(escaped);
- }
- /**
- * Format files as a flat list
- */
- private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
- const lines: string[] = [`## Files (${files.length})`, ''];
- for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
- if (includeMetadata) {
- lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
- } else {
- lines.push(`- ${file.path}`);
- }
- }
- return lines.join('\n');
- }
- /**
- * Format files grouped by language
- */
- private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
- const byLang = new Map<string, typeof files>();
- for (const file of files) {
- const existing = byLang.get(file.language) || [];
- existing.push(file);
- byLang.set(file.language, existing);
- }
- const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
- // Sort languages by file count (descending)
- const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
- for (const [lang, langFiles] of sortedLangs) {
- lines.push(`### ${lang} (${langFiles.length})`);
- for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
- if (includeMetadata) {
- lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
- } else {
- lines.push(`- ${file.path}`);
- }
- }
- lines.push('');
- }
- return lines.join('\n');
- }
- /**
- * Format files as a tree structure
- */
- private formatFilesTree(
- files: { path: string; language: string; nodeCount: number }[],
- includeMetadata: boolean,
- maxDepth?: number
- ): string {
- // Build tree structure
- interface TreeNode {
- name: string;
- children: Map<string, TreeNode>;
- file?: { language: string; nodeCount: number };
- }
- const root: TreeNode = { name: '', children: new Map() };
- for (const file of files) {
- const parts = file.path.split('/');
- let current = root;
- for (let i = 0; i < parts.length; i++) {
- const part = parts[i];
- if (!part) continue;
- if (!current.children.has(part)) {
- current.children.set(part, { name: part, children: new Map() });
- }
- current = current.children.get(part)!;
- // If this is the last part, it's a file
- if (i === parts.length - 1) {
- current.file = { language: file.language, nodeCount: file.nodeCount };
- }
- }
- }
- // Render tree
- const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
- const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
- if (maxDepth !== undefined && depth > maxDepth) return;
- const connector = isLast ? '└── ' : '├── ';
- const childPrefix = isLast ? ' ' : '│ ';
- if (node.name) {
- let line = prefix + connector + node.name;
- if (node.file && includeMetadata) {
- line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
- }
- lines.push(line);
- }
- const children = [...node.children.values()];
- // Sort: directories first, then files, both alphabetically
- children.sort((a, b) => {
- const aIsDir = a.children.size > 0 && !a.file;
- const bIsDir = b.children.size > 0 && !b.file;
- if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
- return a.name.localeCompare(b.name);
- });
- for (let i = 0; i < children.length; i++) {
- const child = children[i]!;
- const nextPrefix = node.name ? prefix + childPrefix : prefix;
- renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
- }
- };
- renderNode(root, '', true, 0);
- return lines.join('\n');
- }
- // =========================================================================
- // Symbol resolution helpers
- // =========================================================================
- /**
- * Find a symbol by name, handling disambiguation when multiple matches exist.
- * Returns the best match and a note about alternatives if any.
- */
- /**
- * Check if a node matches a symbol query.
- *
- * Accepts simple names (`run`) and three flavors of qualifier:
- * - dotted `Session.request` (TS/JS/Python)
- * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
- * - slash `configurator/stage_apply` (path-ish)
- *
- * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
- * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
- * the canonical `crate::module::symbol` form resolves.
- *
- * Resolution order, last part must always equal `node.name`:
- * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
- * where the extractor builds the qualified name from the AST stack)
- * 2. File-path containment (handles file-derived modules in Rust/
- * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
- */
- private matchesSymbol(node: Node, symbol: string): boolean {
- // Simple name match
- if (node.name === symbol) return true;
- // File basename match (e.g., "product-card" matches "product-card.liquid")
- if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
- // Qualified-name lookups: split on any supported separator. `\w` keeps
- // identifier chars (incl. `_`) intact; everything else is treated as
- // a separator we tolerate.
- if (!/[.\/]|::/.test(symbol)) return false;
- const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
- if (parts.length < 2) return false;
- const lastPart = parts[parts.length - 1]!;
- if (node.name !== lastPart) return false;
- // Stage 1: qualified-name suffix match. The extractor joins the
- // semantic hierarchy with `::`, so `Session.request` and
- // `Session::request` both become `Session::request` here.
- const colonSuffix = parts.join('::');
- if (node.qualifiedName.includes(colonSuffix)) return true;
- // Stage 2: file-path containment. Rust modules and Python packages
- // are not in `qualifiedName` — they're encoded in the file path. So
- // `stage_apply::run` matches a `run` in any file whose path
- // contains a `stage_apply` segment (with or without an extension).
- //
- // Filter out Rust path prefixes that have no file-system equivalent.
- const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
- if (containerHints.length === 0) return false;
- const segments = node.filePath.split('/').filter((s) => s.length > 0);
- return containerHints.every((hint) =>
- segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
- );
- }
- private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
- // Use higher limit for qualified lookups (e.g., "Session.request",
- // "stage_apply::run") since the target may rank lower in FTS when
- // there are many partial matches across the qualifier parts.
- const isQualified = /[.\/]|::/.test(symbol);
- const limit = isQualified ? 50 : 10;
- let results = cg.searchNodes(symbol, { limit });
- // FTS strips colons as a special char, so `stage_apply::run` searches
- // for the literal `stage_applyrun` and finds nothing. Re-search by
- // the bare last part and let `matchesSymbol` filter by qualifier.
- if (isQualified && results.length === 0) {
- const tail = lastQualifierPart(symbol);
- if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
- }
- if (results.length === 0 || !results[0]) {
- return null;
- }
- const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
- if (exactMatches.length === 1) {
- return { node: exactMatches[0]!.node, note: '' };
- }
- if (exactMatches.length > 1) {
- // Multiple exact matches - pick first, note the others
- const picked = exactMatches[0]!.node;
- const others = exactMatches.slice(1).map(r =>
- `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
- );
- const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
- return { node: picked, note };
- }
- // No exact match. For qualified lookups, don't silently fall back
- // to a fuzzy result — the user typed a specific qualifier, and
- // resolving `stage_apply::nonexistent_fn` to the unrelated
- // `stage_apply.rs` file would be actively misleading (#173).
- if (isQualified) return null;
- return { node: results[0]!.node, note: '' };
- }
- /**
- * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
- * results across all matching symbols (e.g., multiple classes with an `execute` method).
- */
- private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
- let results = cg.searchNodes(symbol, { limit: 50 });
- // Mirror the fallback in `findSymbol` for qualified queries — FTS
- // strips colons, so a module-qualified lookup needs a second pass
- // by the bare last part.
- if (results.length === 0 && /[.\/]|::/.test(symbol)) {
- const tail = lastQualifierPart(symbol);
- if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
- }
- if (results.length === 0) {
- return { nodes: [], note: '' };
- }
- const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
- if (exactMatches.length <= 1) {
- const node = exactMatches[0]?.node ?? results[0]!.node;
- return { nodes: [node], note: '' };
- }
- const locations = exactMatches.map(r =>
- `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
- );
- const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
- return { nodes: exactMatches.map(r => r.node), note };
- }
- /**
- * Truncate output if it exceeds the maximum length
- */
- private truncateOutput(text: string): string {
- if (text.length <= MAX_OUTPUT_LENGTH) return text;
- const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
- const lastNewline = truncated.lastIndexOf('\n');
- const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
- return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
- }
- // =========================================================================
- // Formatting helpers (compact by default to reduce context usage)
- // =========================================================================
- private formatSearchResults(results: SearchResult[]): string {
- const lines: string[] = [`## Search Results (${results.length} found)`, ''];
- for (const result of results) {
- const { node } = result;
- const location = node.startLine ? `:${node.startLine}` : '';
- // Compact format: one line per result with key info
- lines.push(`### ${node.name} (${node.kind})`);
- lines.push(`${node.filePath}${location}`);
- if (node.signature) lines.push(`\`${node.signature}\``);
- lines.push('');
- }
- return lines.join('\n');
- }
- private formatNodeList(nodes: Node[], title: string): string {
- const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
- for (const node of nodes) {
- const location = node.startLine ? `:${node.startLine}` : '';
- // Compact: just name, kind, location
- lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
- }
- return lines.join('\n');
- }
- private formatImpact(symbol: string, impact: Subgraph): string {
- const nodeCount = impact.nodes.size;
- // Compact format: just list affected symbols grouped by file
- const lines: string[] = [
- `## Impact: "${symbol}" affects ${nodeCount} symbols`,
- '',
- ];
- // Group by file
- const byFile = new Map<string, Node[]>();
- for (const node of impact.nodes.values()) {
- const existing = byFile.get(node.filePath) || [];
- existing.push(node);
- byFile.set(node.filePath, existing);
- }
- for (const [file, nodes] of byFile) {
- lines.push(`**${file}:**`);
- // Compact: inline list
- const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
- lines.push(nodeList);
- lines.push('');
- }
- return lines.join('\n');
- }
- /**
- * Build a compact structural outline of a container symbol from its
- * indexed children (methods, fields, properties, …) — name, kind,
- * line number, and signature — so the agent gets the shape of a class
- * without the full source of every method. Returns '' when the container
- * has no indexed children, so the caller can fall back to full source.
- */
- private buildContainerOutline(cg: CodeGraph, node: Node): string {
- const children = cg.getChildren(node.id)
- .filter(c => c.kind !== 'import' && c.kind !== 'export')
- .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
- if (children.length === 0) return '';
- const lines = [`**Members (${children.length}):**`, ''];
- for (const c of children) {
- const loc = c.startLine ? `:${c.startLine}` : '';
- const sig = c.signature ? ` — \`${c.signature}\`` : '';
- lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
- }
- return lines.join('\n');
- }
- private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
- const location = node.startLine ? `:${node.startLine}` : '';
- const lines: string[] = [
- `## ${node.name} (${node.kind})`,
- '',
- `**Location:** ${node.filePath}${location}`,
- ];
- if (node.signature) {
- lines.push(`**Signature:** \`${node.signature}\``);
- }
- // Only include docstring if it's short and useful
- if (node.docstring && node.docstring.length < 200) {
- lines.push('', node.docstring);
- }
- if (outline) {
- lines.push('', outline, '',
- `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
- } else if (code) {
- lines.push('', '```' + node.language, code, '```');
- }
- return lines.join('\n');
- }
- private formatTaskContext(context: TaskContext): string {
- return context.summary || 'No context found';
- }
- private textResult(text: string): ToolResult {
- return {
- content: [{ type: 'text', text }],
- };
- }
- private errorResult(message: string): ToolResult {
- return {
- content: [{ type: 'text', text: `Error: ${message}` }],
- isError: true,
- };
- }
- }
|