mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-09-19 07:34:57 +08:00
b48170e69f
Two coupled changes addressing the issue's underlying ask — "how does the
agent know when the index lags" — without resorting to a static wait.
Per-file staleness banner
-------------------------
FileWatcher now tracks per-path `pendingFiles` (path, firstSeenMs,
lastSeenMs, indexing) — events since the last successful sync, cleared
only after a sync whose `syncStartedMs >= lastSeenMs` commits. Chokidar
initial-scan events are gated behind a `ready` flag (with `waitUntilReady()`
exposed so tests can deterministically wait through it) so a fresh startup
doesn't falsely flag every existing file as pending.
ToolHandler now wraps every code-returning response (search, context,
callers, callees, impact, trace, explore, node, files) with
`withStalenessNotice`: intersects "files referenced in the response" with
`getPendingFiles()` and emits a hybrid signal —
* banner at the top for files referenced AND pending (with edit age +
indexing/pending-sync state, telling the agent to Read those specific
files directly; the rest of the response stays fresh and codegraph
stays authoritative for it),
* compact footer for pending files elsewhere in the project not
referenced above (capped at 5).
Cost is one boolean check + N substring matches when pending; zero
allocation when idle. `codegraph_status` surfaces the same data as a
first-class `### Pending sync:` section so the agent can ask "is the index
caught up?" in one call.
Cross-project quirk: when an agent passes `projectPath` matching the
default session's project, the staleness wrapper switches from the cached
cross-project CodeGraph (no watcher) to the default one (with watcher) so
the signal still fires. Same fix applied to `handleStatus`.
CODEGRAPH_WATCH_DEBOUNCE_MS
---------------------------
MCP `serve --mcp` now reads `CODEGRAPH_WATCH_DEBOUNCE_MS` and forwards it
to `cg.watch({ debounceMs })`. Clamped to [100ms, 60s]; out-of-range or
non-numeric values fall back to the FileWatcher default (2000ms). Active
value is logged to stderr on watcher startup so it's discoverable. The
docs in `server-instructions.ts`, `installer/instructions-template.ts`,
and `.cursor/rules/codegraph.mdc` no longer claim "~500ms"; they now
describe the banner mechanism instead — since per-file staleness replaces
the "wait N ms" guidance entirely, the docs become accurate at any
debounce value.
Validation
----------
* 847 unit/integration tests pass (added 15 new ones — pending-file
tracking, banner/footer routing, status section, env-var parsing).
* Direct MCP probe through a real `codegraph serve --mcp` process: edit a
file, query within the debounce window, banner fires naming the
edited file with edit-age.
* Real Claude TUI session via `scripts/agent-eval/itrun.sh` with
`CODEGRAPH_WATCH_DEBOUNCE_MS=10000`: agent edits `math.ts`, calls
`codegraph_explore`, reads the banner, **and discloses it unprompted in
its final reply**: "note: symbol index is mid-sync for the new `divide`,
but the source it returned is verbatim from disk."
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2701 lines
111 KiB
TypeScript
2701 lines
111 KiB
TypeScript
/**
|
||
* MCP Tool Definitions
|
||
*
|
||
* Defines the tools exposed by the CodeGraph MCP server.
|
||
*/
|
||
|
||
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
||
import {
|
||
detectWorktreeIndexMismatch,
|
||
worktreeMismatchWarning,
|
||
worktreeMismatchNotice,
|
||
type WorktreeIndexMismatch,
|
||
} from '../sync/worktree';
|
||
import type { PendingFile } from '../sync';
|
||
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, resolve as resolvePath } 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.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Per-file staleness banner emitted at the top of a tool response when the
|
||
* file watcher has pending events for files referenced by the response.
|
||
* The agent uses this to fall back to Read for those specific files
|
||
* without waiting for the debounced sync (issue #403).
|
||
*/
|
||
export function formatStaleBanner(stale: PendingFile[]): string {
|
||
const now = Date.now();
|
||
const lines = stale.map((p) => {
|
||
const ageMs = Math.max(0, now - p.lastSeenMs);
|
||
const label = p.indexing ? 'indexing in progress' : 'pending sync';
|
||
return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
|
||
});
|
||
return (
|
||
'⚠️ Some files referenced below were edited since the last index sync — ' +
|
||
'their codegraph entries may be stale:\n' +
|
||
lines.join('\n') +
|
||
'\nFor accurate content of those specific files, Read them directly. ' +
|
||
'The rest of this response is fresh.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Compact footer listing pending files that are NOT referenced in this
|
||
* response. Gives the agent a complete project-wide freshness picture
|
||
* without bloating the main banner.
|
||
*/
|
||
export function formatStaleFooter(stale: PendingFile[]): string {
|
||
const MAX = 5;
|
||
const now = Date.now();
|
||
const shown = stale.slice(0, MAX);
|
||
const lines = shown.map((p) => {
|
||
const ageMs = Math.max(0, now - p.lastSeenMs);
|
||
return ` - ${p.path} (edited ${ageMs}ms ago)`;
|
||
});
|
||
const more = stale.length > MAX ? `\n - …and ${stale.length - MAX} more` : '';
|
||
return (
|
||
`(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
|
||
`sync but were not referenced above:\n${lines.join('\n')}${more})`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 and its body inlined, plus the outgoing calls of the destination itself) in ONE call. This is something grep/Read structurally cannot do: there is no text pattern for "the path from A to B". Ideal for flow questions — how an update triggers a render, how a request reaches a handler, how a QuerySet becomes SQL. If no static path exists the chain likely breaks at dynamic dispatch (callbacks/descriptors/metaclasses); the tool says where and points you to codegraph_node to bridge it.',
|
||
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;
|
||
// Per-start-path cache of the git worktree/index mismatch (issue #155). The
|
||
// mismatch is a fixed property of (where the request came from → which
|
||
// .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
|
||
// once and every later tool call reuses the result — never shelling out to
|
||
// git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
|
||
private worktreeMismatchCache: Map<string, WorktreeIndexMismatch | null> = new Map();
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
|
||
* env var (comma-separated short names, e.g. "trace,search,node,context").
|
||
* Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
|
||
* trim the tool surface without rebuilding the client config; the ablated
|
||
* tool is then truly absent from ListTools rather than merely denied on call.
|
||
* Matching is on the short form, so "trace" and "codegraph_trace" both work.
|
||
*/
|
||
private toolAllowlist(): Set<string> | null {
|
||
const raw = process.env.CODEGRAPH_MCP_TOOLS;
|
||
if (!raw || !raw.trim()) return null;
|
||
const short = (s: string) => s.trim().replace(/^codegraph_/, '');
|
||
const set = new Set(raw.split(',').map(short).filter(Boolean));
|
||
return set.size ? set : null;
|
||
}
|
||
|
||
/** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
|
||
private isToolAllowed(name: string): boolean {
|
||
const allow = this.toolAllowlist();
|
||
return !allow || allow.has(name.replace(/^codegraph_/, ''));
|
||
}
|
||
|
||
/**
|
||
* 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. Honors the CODEGRAPH_MCP_TOOLS
|
||
* allowlist so a trimmed surface is reflected in ListTools.
|
||
*/
|
||
getTools(): ToolDefinition[] {
|
||
const allow = this.toolAllowlist();
|
||
const visible = allow
|
||
? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
|
||
: tools;
|
||
if (!this.cg) return visible;
|
||
|
||
try {
|
||
const stats = this.cg.getStats();
|
||
const budget = getExploreBudget(stats.fileCount);
|
||
|
||
return visible.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 visible;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
this.worktreeMismatchCache.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;
|
||
}
|
||
|
||
/**
|
||
* Cached git worktree/index mismatch for a tool call's effective project.
|
||
*
|
||
* The "effective project" is what the request targets: an explicit
|
||
* `projectPath` arg, else the directory the server resolved its default
|
||
* project from (`defaultProjectHint`), else cwd. Memoized per start path —
|
||
* see `worktreeMismatchCache`. Best-effort: if the project can't be resolved
|
||
* (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
|
||
* broken by this check.
|
||
*/
|
||
private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null {
|
||
const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
|
||
const cached = this.worktreeMismatchCache.get(startPath);
|
||
if (cached !== undefined) return cached;
|
||
|
||
let mismatch: WorktreeIndexMismatch | null = null;
|
||
try {
|
||
mismatch = detectWorktreeIndexMismatch(startPath, this.getCodeGraph(projectPath).getProjectRoot());
|
||
} catch {
|
||
// No resolvable project (or any other resolution error) → nothing to warn.
|
||
mismatch = null;
|
||
}
|
||
this.worktreeMismatchCache.set(startPath, mismatch);
|
||
return mismatch;
|
||
}
|
||
|
||
/**
|
||
* Prefix a successful read-tool result with a compact worktree-mismatch
|
||
* notice when the resolved index belongs to a different git working tree than
|
||
* the caller's (issue #155). Without this, an agent in a nested worktree
|
||
* silently trusts main-branch results. No-op on error results and when there
|
||
* is no mismatch. `codegraph_status` is excluded — it embeds its own verbose
|
||
* warning — so it stays out of this path.
|
||
*/
|
||
private withWorktreeNotice(result: ToolResult, projectPath?: string): ToolResult {
|
||
if (result.isError) return result;
|
||
const mismatch = this.worktreeMismatchFor(projectPath);
|
||
if (!mismatch) return result;
|
||
|
||
const notice = worktreeMismatchNotice(mismatch);
|
||
const [first, ...rest] = result.content;
|
||
if (first && first.type === 'text') {
|
||
return { ...result, content: [{ type: 'text', text: `${notice}\n\n${first.text}` }, ...rest] };
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Annotate a successful read-tool result with per-file staleness — the
|
||
* non-blocking answer to issue #403. The file watcher tracks every event
|
||
* it sees per path; here we intersect "files referenced in this response"
|
||
* against that pending set and prepend a compact banner so the agent can
|
||
* fall back to Read for those *specific* files without waiting for the
|
||
* debounced sync to fire. Other pending files in the project (not
|
||
* referenced by this response) get a small footer so the agent has a
|
||
* complete picture without bloating the banner.
|
||
*
|
||
* Cost when nothing is pending — the common case — is one boolean check.
|
||
* No I/O, no parsing of markdown beyond a per-pending-file substring scan.
|
||
*/
|
||
private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult {
|
||
if (result.isError) return result;
|
||
|
||
let cg: CodeGraph;
|
||
try {
|
||
cg = this.getCodeGraph(projectPath);
|
||
} catch {
|
||
return result; // no default project — leave as is
|
||
}
|
||
|
||
// Cross-project `projectPath` calls open a cached CodeGraph WITHOUT a
|
||
// watcher (watchers are only attached to the default session project).
|
||
// When the cross-project path happens to be the same project as the
|
||
// default cg, the cached instance is the wrong one — its pendingFiles is
|
||
// permanently empty. Detect the equal-path case and prefer the default
|
||
// cg so the staleness signal still fires when an agent passes the
|
||
// explicit projectPath form of its own project.
|
||
if (this.cg && cg !== this.cg) {
|
||
try {
|
||
const sameProject =
|
||
resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot());
|
||
if (sameProject) cg = this.cg;
|
||
} catch {
|
||
/* getProjectRoot may throw on a closed instance — leave cg as is */
|
||
}
|
||
}
|
||
|
||
// Defensive: some test fakes inject a partial CodeGraph stub without the
|
||
// newer pending-files API. Treat missing/throwing as "no pending files."
|
||
let pending: PendingFile[] = [];
|
||
try {
|
||
pending = cg.getPendingFiles?.() ?? [];
|
||
} catch {
|
||
return result;
|
||
}
|
||
if (pending.length === 0) return result;
|
||
|
||
const [first, ...rest] = result.content;
|
||
if (!first || first.type !== 'text') return result;
|
||
|
||
const text = first.text;
|
||
const inResponse: PendingFile[] = [];
|
||
const elsewhere: PendingFile[] = [];
|
||
for (const p of pending) {
|
||
// Substring match against the project-relative POSIX path — that's
|
||
// exactly the format both the watcher and every codegraph response
|
||
// emit, so a plain includes() is sufficient and avoids regex pitfalls.
|
||
if (text.includes(p.path)) inResponse.push(p);
|
||
else elsewhere.push(p);
|
||
}
|
||
|
||
let banner = '';
|
||
if (inResponse.length > 0) {
|
||
banner = formatStaleBanner(inResponse);
|
||
}
|
||
let footer = '';
|
||
if (elsewhere.length > 0) {
|
||
footer = formatStaleFooter(elsewhere);
|
||
}
|
||
if (!banner && !footer) return result;
|
||
|
||
const composed = [banner, text, footer].filter(Boolean).join('\n\n');
|
||
return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
|
||
}
|
||
|
||
/**
|
||
* Execute a tool by name
|
||
*/
|
||
async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
|
||
try {
|
||
// Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
|
||
// surface rejects ablated tools defensively even if a client cached them.
|
||
if (!this.isToolAllowed(toolName)) {
|
||
return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
|
||
}
|
||
// 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;
|
||
}
|
||
|
||
// Read tools resolve through a single result variable so cross-cutting
|
||
// notices — worktree-index mismatch (issue #155) and per-file
|
||
// staleness (issue #403) — can be applied in one place. status embeds
|
||
// its own verbose worktree warning but still flows through the
|
||
// staleness wrapper so its pending-files section stays consistent
|
||
// with what the read tools surface.
|
||
let result: ToolResult;
|
||
switch (toolName) {
|
||
case 'codegraph_search':
|
||
result = await this.handleSearch(args); break;
|
||
case 'codegraph_context':
|
||
result = await this.handleContext(args); break;
|
||
case 'codegraph_callers':
|
||
result = await this.handleCallers(args); break;
|
||
case 'codegraph_callees':
|
||
result = await this.handleCallees(args); break;
|
||
case 'codegraph_impact':
|
||
result = await this.handleImpact(args); break;
|
||
case 'codegraph_explore':
|
||
result = await this.handleExplore(args); break;
|
||
case 'codegraph_node':
|
||
result = await this.handleNode(args); break;
|
||
case 'codegraph_status':
|
||
// status embeds the pending-files list as a first-class section
|
||
// (see handleStatus), so we skip the auto-banner wrapper here to
|
||
// avoid duplicating the same info at the top of the response.
|
||
return await this.handleStatus(args);
|
||
case 'codegraph_files':
|
||
result = await this.handleFiles(args); break;
|
||
case 'codegraph_trace':
|
||
result = await this.handleTrace(args); break;
|
||
default:
|
||
return this.errorResult(`Unknown tool: ${toolName}`);
|
||
}
|
||
const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
|
||
return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
|
||
} 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}`,
|
||
'',
|
||
`Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
|
||
'',
|
||
`${path.length} hops:`,
|
||
'',
|
||
];
|
||
// Inline what each hop needs so the agent doesn't Read/Grep to get it: the
|
||
// call-site source line, the registration site for dynamic-dispatch hops, AND
|
||
// the hop's own body (capped per hop so the trace stays path-scoped). Earlier
|
||
// versions inlined only the call-site line, which left agents calling explore
|
||
// or Read for the bodies — the exact follow-up the ablation experiment measured.
|
||
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}-${step.node.endLine})`);
|
||
const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
|
||
if (body) lines.push(body);
|
||
}
|
||
// The "last mile": what the destination does next. Agents otherwise explore/Read
|
||
// for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
|
||
// so inlining the destination's callees is what actually stops the investigation —
|
||
// sufficiency, not a "don't explore" instruction.
|
||
const dest = path[path.length - 1]!.node;
|
||
const destCallees = cg.getCallees(dest.id)
|
||
.filter(c => !path.some(p => p.node.id === c.node.id))
|
||
.slice(0, 6);
|
||
if (destCallees.length > 0) {
|
||
lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
|
||
for (const c of destCallees) {
|
||
lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
|
||
const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
|
||
if (body) lines.push(body);
|
||
}
|
||
}
|
||
lines.push('', '> Full path + every hop body + the destination\'s calls are inlined above — the complete flow. Answer from it; a Read is only needed to chase a specific local variable\'s data-flow.');
|
||
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,
|
||
};
|
||
}
|
||
if (m?.synthesizedBy === 'vue-handler') {
|
||
const ev = m.event ? `@${String(m.event)}` : 'a template event';
|
||
return {
|
||
label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
|
||
compact: `dynamic: Vue ${ev} handler`,
|
||
registeredAt,
|
||
};
|
||
}
|
||
if (m?.synthesizedBy === 'interface-impl') {
|
||
return {
|
||
label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
|
||
compact: `dynamic: interface → impl${at}`,
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Read a hop's body — filePath lines [startLine..endLine] — for inlining into
|
||
* a trace, capped (lines + chars) so the whole path stays path-scoped even on
|
||
* a 7-hop chain. Dedents to the body's own indentation and marks truncation.
|
||
* Shares `cache` with sourceLineAt so each file is read at most once per trace.
|
||
*/
|
||
private sourceRangeAt(
|
||
cg: CodeGraph,
|
||
filePath: string,
|
||
startLine: number,
|
||
endLine: number,
|
||
cache: Map<string, string[]>,
|
||
maxLines = 28,
|
||
maxChars = 1200
|
||
): string | null {
|
||
if (!Number.isFinite(startLine) || startLine < 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 end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
|
||
let slice = fileLines.slice(startLine - 1, end);
|
||
if (slice.length === 0) return null;
|
||
let omitted = 0;
|
||
if (slice.length > maxLines) { omitted = slice.length - maxLines; slice = slice.slice(0, maxLines); }
|
||
const nonBlank = slice.filter(l => l.trim().length > 0);
|
||
const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
|
||
let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
|
||
if (text.length > maxChars) {
|
||
text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
|
||
omitted = Math.max(omitted, 1);
|
||
}
|
||
if (omitted > 0) text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
|
||
* symbol names that usually spans the flow it's investigating (e.g.
|
||
* "PmsProductController getList PmsProductService list PmsProductServiceImpl").
|
||
* Surface the longest call chain AMONG those named symbols — scoped to what the
|
||
* agent explicitly named, so (unlike a fuzzy relevance set) there's no
|
||
* wrong-feature wandering. Rides synthesized edges, so controller→service-
|
||
* interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
|
||
*
|
||
* Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
|
||
* CO-NAMING: the agent names the class too, so we keep only `list` candidates
|
||
* whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
|
||
* dropping unrelated `OmsOrderService::list`.
|
||
*/
|
||
private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): string {
|
||
try {
|
||
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
||
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
|
||
// names (Class.method / Class::method) — the agent's most precise input,
|
||
// resolved exactly by findAllSymbols. (The old strip mangled Class.method
|
||
// into Class, throwing the method away.)
|
||
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
|
||
const tokens = [...new Set(
|
||
query.split(/[\s,()[\]]+/)
|
||
.map((t) => t.replace(FILE_EXT, '').trim())
|
||
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
|
||
)].slice(0, 16);
|
||
if (tokens.length < 2) return '';
|
||
// Pool of name SEGMENTS (Class + method from every token) used to
|
||
// disambiguate an ambiguous SIMPLE name: keep a candidate only if its
|
||
// CONTAINER class is itself named in the query.
|
||
const segPool = new Set<string>();
|
||
for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
|
||
const named = new Map<string, Node>();
|
||
for (const t of tokens) {
|
||
const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
|
||
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
||
// ambiguous simple name keeps only candidates whose container is named.
|
||
const pick = cands.length <= 3
|
||
? cands
|
||
: cands.filter((n) => {
|
||
const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
|
||
const container = segs.length >= 2 ? segs[segs.length - 2] : '';
|
||
return !!container && segPool.has(container);
|
||
});
|
||
for (const n of pick.slice(0, 6)) named.set(n.id, n);
|
||
if (named.size > 40) break;
|
||
}
|
||
if (named.size < 2) return '';
|
||
const MAX_HOPS = 7;
|
||
let best: Array<{ node: Node; edge: Edge | null }> | null = null;
|
||
// BFS the full call graph (incl. synth edges) from each named seed, but
|
||
// only ACCEPT a sink that is also named — both ends anchored to symbols the
|
||
// agent named, so the chain stays on-topic while bridging intermediates
|
||
// (e.g. the exact interface overload) that the token resolution missed.
|
||
for (const seed of [...named.values()].slice(0, 8)) {
|
||
const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
|
||
parent.set(seed.id, { prev: null, edge: null, node: seed });
|
||
const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
|
||
let deep: string | null = null, deepDepth = 0;
|
||
const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
|
||
for (let h = 0; h < q.length && parent.size < 1500; h++) {
|
||
const { id, depth, streak } = q[h]!;
|
||
if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
|
||
if (depth >= MAX_HOPS - 1) continue;
|
||
for (const c of cg.getCallees(id)) {
|
||
if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
|
||
const newStreak = named.has(c.node.id) ? 0 : streak + 1;
|
||
if (newStreak > MAX_BRIDGE) continue;
|
||
parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
|
||
q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
|
||
}
|
||
}
|
||
if (!deep) continue;
|
||
const chain: Array<{ node: Node; edge: Edge | null }> = [];
|
||
let cur: string | null = deep;
|
||
while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
|
||
chain.reverse();
|
||
if (!best || chain.length > best.length) best = chain;
|
||
}
|
||
if (!best || best.length < 3) return '';
|
||
const out = ['## Flow (call path among the symbols you queried)', ''];
|
||
for (let i = 0; i < best.length; i++) {
|
||
const step = best[i]!;
|
||
if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`); }
|
||
out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
|
||
}
|
||
out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
|
||
return out.join('\n');
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = this.buildFlowFromNamedSymbols(cg, query) + 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> {
|
||
let cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||
// Same trick as withStalenessNotice — when an explicit projectPath
|
||
// resolves to the same project as the default session cg, prefer the
|
||
// default so getPendingFiles() (only populated by the default's watcher)
|
||
// is non-empty when there are pending edits.
|
||
if (this.cg && cg !== this.cg) {
|
||
try {
|
||
if (resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot())) {
|
||
cg = this.cg;
|
||
}
|
||
} catch { /* closed instance — leave as is */ }
|
||
}
|
||
const stats = cg.getStats();
|
||
|
||
// Warn when this index actually belongs to a different git working tree
|
||
// (e.g. the server resolved up from a nested worktree to the main checkout).
|
||
// Queries then reflect that tree's branch, not the worktree being edited.
|
||
// status shows the verbose, multi-line form; the read tools get the compact
|
||
// one-liner via withWorktreeNotice. Both share the cached detection.
|
||
const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined);
|
||
|
||
const lines: string[] = [
|
||
'## CodeGraph Status',
|
||
'',
|
||
];
|
||
if (mismatch) {
|
||
lines.push(`> ⚠ ${worktreeMismatchWarning(mismatch).replace(/\n/g, '\n> ')}`, '');
|
||
}
|
||
lines.push(
|
||
`**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}`);
|
||
}
|
||
}
|
||
|
||
// Per-file freshness — the inverse of the auto-prepended staleness banner
|
||
// (issue #403). Surfacing it inside `status` gives the agent a single
|
||
// place to ask "is the index caught up?" rather than inferring from
|
||
// banners on other tool calls.
|
||
const pending = cg.getPendingFiles();
|
||
if (pending.length > 0) {
|
||
lines.push('', '### Pending sync:');
|
||
const now = Date.now();
|
||
for (const p of pending) {
|
||
const ageMs = Math.max(0, now - p.lastSeenMs);
|
||
const label = p.indexing ? 'indexing in progress' : 'pending sync';
|
||
lines.push(`- ${p.path} (edited ${ageMs}ms ago, ${label})`);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// Line-numbered (cat -n style, like codegraph_explore and Read) so the
|
||
// agent can cite/edit exact lines without re-Reading the file for them.
|
||
const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
|
||
lines.push('', '```' + node.language, numbered, '```');
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
}
|